diff --git a/Makefile b/Makefile
index 2d9b3fa2..0147c5d9 100644
--- a/Makefile
+++ b/Makefile
@@ -218,14 +218,15 @@ include src/sdkHeaders.mk
# The SDK's own files (README, sdk.mk, newProject.sh, project templates)
# live in src/sdk; its bin/ holds the host tools a project build runs.
SDK_SRC = src/sdk
-SDK_TOOLS = bin/host/dvxres bin/host/dvxhlpc bin/host/bascomp bin/host/mkfont src/tools/dxeGen.sh
+SDK_TOOLS = bin/host/dvxres bin/host/dvxhlpc bin/host/bascomp bin/host/mkfont bin/host/dvxpkg src/tools/dxeGen.sh
-# The host tools come from tools (dvxres, dvxhlpc, mkfont) and apps (bascomp).
-deploy-sdk: tools apps
+# The host tools come from tools (dvxres, dvxhlpc, mkfont), apps (bascomp),
+# and installer (dvxpkg).
+deploy-sdk: tools apps installer
@echo "Building SDK..."
@# Start from an empty tree so a header dropped from the lists goes away.
rm -rf $(SDKDIR)
- @mkdir -p $(SDKDIR)/bin $(SDKDIR)/include/core $(SDKDIR)/include/shell $(SDKDIR)/include/tasks $(SDKDIR)/include/sql $(SDKDIR)/include/texthelp $(SDKDIR)/include/listhelp $(SDKDIR)/include/richtext $(SDKDIR)/include/fileops $(SDKDIR)/include/hlpfile $(SDKDIR)/include/xfer $(SDKDIR)/include/conn $(SDKDIR)/include/print $(SDKDIR)/include/game $(SDKDIR)/include/joystick $(SDKDIR)/include/audio $(SDKDIR)/include/music $(SDKDIR)/include/net $(SDKDIR)/include/basic $(SDKDIR)/samples/basic/basdemo $(SDKDIR)/samples/basic/widshow
+ @mkdir -p $(SDKDIR)/bin $(SDKDIR)/include/core $(SDKDIR)/include/shell $(SDKDIR)/include/tasks $(SDKDIR)/include/sql $(SDKDIR)/include/texthelp $(SDKDIR)/include/listhelp $(SDKDIR)/include/richtext $(SDKDIR)/include/fileops $(SDKDIR)/include/hlpfile $(SDKDIR)/include/xfer $(SDKDIR)/include/conn $(SDKDIR)/include/print $(SDKDIR)/include/game $(SDKDIR)/include/joystick $(SDKDIR)/include/audio $(SDKDIR)/include/music $(SDKDIR)/include/pkg $(SDKDIR)/include/json $(SDKDIR)/include/http $(SDKDIR)/include/pkgnet $(SDKDIR)/include/net $(SDKDIR)/include/basic $(SDKDIR)/samples/basic/basdemo $(SDKDIR)/samples/basic/widshow
cp $(SDK_CORE_H) $(SDKDIR)/include/core/
cp $(SDK_SHELL_H) $(SDKDIR)/include/shell/
cp $(SDK_TASKS_H) $(SDKDIR)/include/tasks/
@@ -242,6 +243,10 @@ deploy-sdk: tools apps
cp $(SDK_JOYSTICK_H) $(SDKDIR)/include/joystick/
cp $(SDK_AUDIO_H) $(SDKDIR)/include/audio/
cp $(SDK_MUSIC_H) $(SDKDIR)/include/music/
+ cp $(SDK_PKG_H) $(SDKDIR)/include/pkg/
+ cp $(SDK_JSON_H) $(SDKDIR)/include/json/
+ cp $(SDK_HTTP_H) $(SDKDIR)/include/http/
+ cp $(SDK_PKGNET_H) $(SDKDIR)/include/pkgnet/
@# Networking: dvxNet.h and the standard socket headers, which keep their
@# subdirectories (sys/, netinet/, arpa/); sdk.mk adds include/net only
@# for programs that set NET = 1.
diff --git a/config/ASSOC.INI b/config/ASSOC.INI
index cdb8bc90..37ce1201 100644
--- a/config/ASSOC.INI
+++ b/config/ASSOC.INI
@@ -23,3 +23,4 @@ CLS = apps/kpunch/dvxbasic/dvxbasic.app
CTL = apps/kpunch/dvxbasic/dvxbasic.app
HLP = apps/kpunch/dvxhelp/dvxhelp.app
DHS = apps/kpunch/dvxhelp/helpedit.app
+DPK = apps/kpunch/software/software.app
diff --git a/config/README.md b/config/README.md
index 2a7f8446..86fe89e1 100644
--- a/config/README.md
+++ b/config/README.md
@@ -23,6 +23,7 @@ the shipped list.
| `wpaper/blueglow.jpg` | `bin/shared/wpaper/blueglow.jpg` | Wallpaper image |
| `wpaper/swoop.jpg` | `bin/shared/wpaper/swoop.jpg` | Wallpaper image |
| `wpaper/triangle.jpg` | `bin/shared/wpaper/triangle.jpg` | Wallpaper image |
+| `keys/*.KEY` | `bin/system/KEYS/*.KEY` | DVX's repository key: packages it signs (DVX's own, and everything the package server publishes) are trusted as DVX's |
The desktop sounds (`bin/shared/sounds/`) are made by mksounds and the
fonts (`bin/shared/fonts/`) by mkfont (src/tools).
@@ -193,3 +194,18 @@ ignored. Names are case-insensitive.
Wallpapers support BMP, PNG, JPEG, and GIF formats. They are
pre-rendered to screen dimensions in native pixel format at load time.
+
+## The Repository Key
+
+DVX trusts as its own the packages signed by the keys in `SYSTEM\KEYS`,
+and refuses an update of DVX itself (`kpunch.dvx`) signed by any other.
+The key pair is made once, on a computer the package server never
+touches:
+
+ bin/host/dvxpkg keygen "DVX Repository" KPUNCH
+
+`KPUNCH.KEY` (the public half) goes in `config/keys/`, so every build
+ships it. `KPUNCH.SEC` stays offline, with the operator who approves
+packages (server/README.md); `config/keys/.gitignore` keeps a stray copy
+of it out of the repository.
+
diff --git a/config/dvx.ini b/config/dvx.ini
index f4418dc8..471f6af0 100644
--- a/config/dvx.ini
+++ b/config/dvx.ini
@@ -51,6 +51,19 @@ dns2 =
hostname = DVX
vector = auto
+; The package server (Software's File > Package Server): Software gets
+; packages and updates from it, and publishes with the account.
+; server: its host name, port: its port (both empty: DVX's own server)
+; user, password: an account there, to publish packages (empty: none).
+; Software saves the password scrambled; one typed here by hand is read
+; as it is and scrambled the next time Software saves the settings.
+
+[packages]
+server =
+port =
+user =
+password =
+
; Printer settings (Control Panel's Printer page and File > Page Setup).
; port: LPT1, LPT2, LPT3 or FILE (print into the file named by file)
; model: text (any printer, text only), epson9, epson24 (Epson ESC/P
@@ -96,8 +109,9 @@ footer = Page &p
; mpuMode: what is on the MIDI port: gm (a General MIDI synthesizer),
; mt32 (a Roland MT-32) or raw (send music unchanged)
; Optional: oplPort (default 388) and mpuPort (default 330), in hex;
-; midiBank = the instrument bank file for MIDI music (OP2 format, path
-; from the DVX folder; default libs\kpunch\music\genmidi.op2)
+; midiBank = the instrument bank for MIDI music (OP2 format): its name in
+; SHARED\BANKS or USER\BANKS (an add-on's as VENDOR\NAME), or a path from
+; the DVX folder; not set, the bundled one. Control Panel picks it.
; The Sound Blaster's port, IRQ and DMA come from the BLASTER setting (for
; example SET BLASTER=A220 I5 D1 H5 in AUTOEXEC.BAT).
diff --git a/config/keys/.gitignore b/config/keys/.gitignore
new file mode 100644
index 00000000..806ccbfb
--- /dev/null
+++ b/config/keys/.gitignore
@@ -0,0 +1,3 @@
+# Secret keys never belong in the repository: only the public KPUNCH.KEY goes here
+*.SEC
+*.sec
diff --git a/config/keys/KPUNCH.KEY b/config/keys/KPUNCH.KEY
new file mode 100644
index 00000000..7dcd002e
--- /dev/null
+++ b/config/keys/KPUNCH.KEY
@@ -0,0 +1,4 @@
+[key]
+name = DVX Repository
+id = f32a2ff2e9471fd7
+public = 1f67ffe49191d83792c8bba8d10b5abaa890db05421e6001d7d3b9b6093c2f10
diff --git a/docs/dvx_basic_reference.html b/docs/dvx_basic_reference.html
index 10bc6814..753fdbc4 100644
--- a/docs/dvx_basic_reference.html
+++ b/docs/dvx_basic_reference.html
@@ -46,6 +46,7 @@ img { max-width: 100%; }
@@ -1502,6 +1529,7 @@ img { max-width: 100%; }
---
Make Executable... Compile the project and save it as a standalone .app file. Prompts for a debug or release build. A release build leaves out debugging information and gives forms and controls short internal names, except in a program that uses names as text (the Name property, Controls, DesignSelection, or a DesignMouse event procedure), which keeps them.
Make Control... In a control project (Project Properties, Type Control) this item replaces Make Executable: it compiles the project and saves its controls as a control file (.wgt, named after the project). See UserControls in the IDE.
+ Make Package... Make the program (or, in a control project, the control file) and its help file into an add-on package (.DPK) that anyone can install with Software. See Making a Package.
---
Rename File... Give the file selected in the Project Explorer a new name, on disk and in the project. See Renaming a File under Project System.
Remove File Remove the selected file from the project (prompts to save if modified).
@@ -1745,7 +1773,7 @@ Count = 1
New UserControl Adds a new UserControl to the project and opens it (see UserControls in the IDE).
Rename File Gives a file a new name on disk and in the project file (see Renaming a File below).
Remove File Removes a file from the project (prompts to save if modified). Removing the startup form clears the Startup Form setting.
- Project Properties Opens a dialog for editing project metadata (name, type, author, company, version, copyright, description, icon, help file, startup form), the fonts the program carries, whether more than one copy may run at once, and the Arguments for runs from the IDE.
+ Project Properties Opens a dialog for editing project metadata (name, type, author, company, version, copyright, description, icon, help file, startup form), the fonts the program carries, whether more than one copy may run at once, the Arguments for runs from the IDE, and the Package and Needs that File > Make Package uses.
Renaming a File
Select a file in the Project Explorer and choose File > Rename File. Type the new file name; the extension may be left out (a module stays .bas, a class module .cls, a UserControl .ctl, and a form .frm, and cannot be given another kind's extension). The file is renamed in the project's directory and the project file is saved at once with the new name, so the two always agree. Nothing changes, and a message says why, when the name is not a valid file name, when another project file or any file in the directory already has it, or when the file or the project file cannot be written -- an existing file is never overwritten. A file that has never been saved is simply given the new name, and is written under it when you save.
A class module named after its file (it has no Attribute VB_Name line, or one that matches the file name, as File > New Class Module writes it) takes the new name as its class name: its Attribute VB_Name line is changed to match (an edit that is saved with the file, like any other). The new name must then be a valid name that no other form, class module, or UserControl uses. Code that uses the old class name is not changed. A UserControl named after its file (as File > New UserControl makes it) takes the new name too, on its Begin UserControl line; forms that use the old name are not changed. A form keeps its Name property; rename the form itself in the Properties window.
File > Make Package turns the project into an add-on package: one .DPK file holding the program (built as a release build) and its help file, which anyone can install with the Software program -- it puts the program in Program Manager's Installed Programs -- and remove again. In a control project the package holds the control file instead, and installs it for every program to use.
+
The first time, Make Package asks for the package name: who made it and what it is, with a dot between them, such as acme.chess (each part 1 to 8 lower-case letters, digits, or _). It suggests one from the project's Publisher and Name. Keep the same package name for every version, so that Software installs a new version over the old one. The name is kept in the project and can be changed under File > Project Properties (Package).
+
It then asks where to save the package and which key to sign it with. Signing tells the people who install the package that it came from you and was not changed on the way; the list offers the keys in your KEYS folder (make one with Package Builder's New Key) and Not signed. The key chosen is offered first the next time.
+
The package takes the rest from the project: its name, Version (give each version you hand out a higher one, such as 1.0.0, then 1.1.0), Description (shown as its summary), Publisher, and Author. When the program needs other add-on packages to work, list them under Project Properties, Needs: each package name with the oldest version that will do, with commas between them, such as acme.cards 1.0, acme.dice 2.1 (a package name alone takes any version). DVX's own libraries need not be listed.
+
A program that needs more files than its help -- data files, or files in other folders -- is packaged with Package Builder, which takes a whole folder.
A UserControl is a control you write in BASIC (see UserControls: Your Own Controls in the BASIC Language Reference for how to write one). This topic covers making one in the IDE, editing it, trying it on a form, and turning it into a control file every program can use.
@@ -1801,7 +1839,7 @@ End Sub
Width, Height Enter a whole number of pixels (at least 1): the control's natural size.
CanGetFocus Each double-click switches between True and False.
BackColor Pick a color in the color dialog.
- ToolboxIcon Type the name of a 24 x 24 picture file in the project's folder.
+ ToolboxIcon Type the name of a 24 x 24 picture file in the project's folder, or click Browse... to pick it.
Description Type the toolbox tooltip.
DefaultEvent Pick one of the control's Events (or none): the event the designer opens when a placed control is double-clicked.
The change is written into the header lines of the file (the rest of the file stays as it is) and saved with the file. The Code Editor shows the new header; breakpoints in the code move with the lines below it. You can also edit the header lines in the Code Editor by hand.
@@ -1827,7 +1865,7 @@ End Sub
The Properties panel (Window > Properties) has two sections:
Control tree -- a TreeView at the top listing the form and all its controls in layout order. Click a control name to select it in both the Properties panel and the Form Designer. Drag items in the tree to reorder controls in the form's layout.
-
Property list -- a two-column ListView below the tree showing property names and values for the selected control. Double-click a property value to edit it via an InputBox dialog. Changes take effect immediately in the designer preview.
+
Property list -- a two-column ListView below the tree showing property names and values for the selected control. Double-click a property value to edit it via an InputBox dialog. A file property (Picture, DragIcon, the form's Icon, and a Data control's DatabaseName) has Browse... beside its box: a file picked in the project's folder (or a folder inside it) is kept relative to the project, so the project still works when it is moved. Changes take effect immediately in the designer preview.
Each control type exposes different properties (e.g., Caption, Text, Width, Height, MaxWidth, MaxHeight, Weight, Alignment, Enabled, Visible, and type-specific properties like DataSource and DataField for data-bound controls). A UserControl placed on a form lists the properties its code defines.
The Font row shows the font the selected control, or the form, draws with: its family, size in pixels, and styles, such as "DejaVu Sans, 14, Bold". Double-click it to choose a font in the Font dialog; the design surface shows it at once. The choice is kept as the six Font properties (FontName, FontSize, FontBold, FontItalic, FontUnderline, and FontStrikethru), which then appear in the list and can be changed one at a time too. Choosing the font the control would have anyway (its container's or the form's) removes them, so the control follows the form's font again. A font chosen for the form is used by every control that has none of its own. For a TextBox, TextArea, ComboBox, SpinButton, or Terminal the dialog lists only fixed-pitch families.
@@ -2258,6 +2296,7 @@ End Sub
Paths may be written with / or \ wherever a path is taken (OPEN, KILL, NAME, MKDIR, CHDIR, DIR$, FILELEN, and the file and folder functions of the libraries), mixed too; paths DVX gives back (CURDIR$, App.Path, App.Data, the file dialogs, the FileOps results) use \, as DOS shows them. New: FileOpsPathNative$ gives a path of your own that form. DIR$("C:\*.*") lists the root of C: (before, it listed C:'s current folder). SHELL commands still go to DOS as written, where / starts a switch.
The IDE shows a project's file paths with \ and keeps writing them with / in the .dbp file, so a project reads the same everywhere.
The Open and Save As dialogs (DlgFileOpen$, DlgFileSave$, and the IDE's own) have a Drive list beside the Path box (Alt+D); a drive with no disk in it says it is not ready.
+
New: DlgFolder$(title, folder) in commdlg.bas lets the user choose a folder, existing or new, for a Browse button beside a folder box. A title of "" gives any of the file dialogs its usual title (it used to leave the title bar empty).
Changed: FileOpsValidName takes the folder the name is for, FileOpsValidName(folder, itemName), since the rules depend on its disk; XferSafeName$ takes the folder a file is received into, XferSafeName$(name, folder). New: FileOpsLongNames, FileOpsShortPath$, FileOpsValidLongName, and FileOpsValidShortName (fileops.bas), and ShellArgQuote$ (shell.bas), which puts a path with spaces in quotes for a command line.
Programs compiled with an older release must be rebuilt. A program whose procedure table records more than 64 locals for a procedure is refused when it is loaded.
A class may have at most 65536 member variables and a form at most 65536 form-level variables (as many as the compiled program can number); more is a compile error ("Too many member variables in X", "Too many form-level variables"), and a program file that records more is refused when it is loaded.
@@ -2300,6 +2339,7 @@ End Sub
IDE
+
Properties window: Picture, DragIcon, the form's Icon, DatabaseName, and a UserControl's ToolboxIcon have Browse... beside the box; a file in the project's folder goes in relative to it.
Immediate window: Enter evaluates the line it is pressed on, assignments accept any expression, and a statement that does not finish is stopped.
A compile error puts the caret on the column of the error, not just its line.
Output window: always shows the newest output and drops the oldest lines when full; Output Window to Log also logs errors.
@@ -4902,7 +4942,7 @@ End Sub
DVX ships a set of BASIC include files (.bas) in the sdk/include/basic directory. Each file is a small library of DECLARE LIBRARY wrappers around native DVX runtime functions. Add an include file to your project (the IDE's Add File command, or editing the .dbp with another tool) and the declared functions become available to your code.
File Provides
---- --------
- commdlg.bas File Open/Save dialogs (DlgFileOpenIn$ and DlgFileSaveIn$ start in a folder you name), input dialogs, choice dialogs, save-prompt dialogs, the color dialog, the font dialog (DlgFont), message boxes with your own buttons (DlgButtons)
+ commdlg.bas File Open/Save dialogs (DlgFileOpenIn$ and DlgFileSaveIn$ start in a folder you name), the folder dialog (DlgFolder$, for a Browse button), input dialogs, choice dialogs, save-prompt dialogs, the color dialog, the font dialog (DlgFont), message boxes with your own buttons (DlgButtons)
sql.bas SQLite database access (SQLOpen, SQLQuery, SQLNext, SQLField$, etc.)
comm.bas Serial-port I/O (raw and packet) and optional encrypted links; port settings read back and changed, why a call failed, breaks, ring, and line errors (SerOpen, SerSet, SerLastError, CommOpen, CommHandshakeStep, ...)
conn.bas Connections the way Terminal makes them: a serial port, a modem that dials, the secure link to the DVX host proxy, or Telnet, shown on a Terminal control, with capture, answerback, and file transfers (ConnCreate, ConnOpen, ConnPoll, ConnAttach, ConnSendFiles, ...)
@@ -4927,7 +4967,11 @@ End Sub
compiler.bas The BASIC compiler: compile a project or files you give it, find compile errors in their files, list a program's procedures and the language's keywords, build an .app or a control file; put a project's UserControls on a form designer's toolbox; read source text as the compiler does (tokens, code or string or comment, renaming a control in code, WithEvents variables, class Events), F1 help topics; open, change, and save project files and UserControl headers (CompSourceFromProject, CompSourceAdd, CompCompile, CompError$, CompMapFile$, CompProcList$, CompBuildApp, CompDesignControls, CompTokens$, CompRenameInCode$, CompHelpTopic$, ProjOpen, ProjSave, ProjCtlSetHeader$, ...)
debug.bas Run a compiled program inside yours and debug it: breakpoints, stepping, its variables, expressions, Immediate-window lines, its PRINT output (RunCreate, RunStart, RunPoll, DbgSetBreakpoints, DbgStepOver, DbgLocals$, DbgEval$, DbgExec$, ...)
designer.bas Form designers: the control types, whether each holds other controls and can be placed, the names of new controls, default events, toolbox tooltips and icons, and each type's properties (with their types and value names), methods, and events (with each event's parameters); the names in .frm and class module text; .frm files as data to read, edit, and write, controls copied and pasted as text, and a loaded form written back as .frm text (CtlTypeCount, CtlTypeName$, CtlTypeFlags, CtlPropCount, CtlPropName$, CtlPropType, CtlEventList$, CtlEventParams$, CtlTypeEventParams$, FrmFormName$, FrmControlNames$, ClsClassName$, FrmOpen, FrmText$, FrmCtlAdd, FrmCtlText$, FrmCtlPaste, FrmMenuAdd, FormText$, ...)
- shell.bas DVX itself, everything Program Manager and Task Manager do: start programs and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...)
+ shell.bas DVX itself, everything Program Manager and Task Manager do: start programs and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...)
+ pkg.bas Add-on packages: the packages installed in DVX and their details, files, and needs; reading .DPK files and planning what installing them takes; installing and removing as a job with progress; trusting a publisher's key; and making packages, signing them, and making keys (PkgOpen, PkgInstalledRows$, PkgOfferFile, PkgPlan, PkgInstallStart, PkgRunFor, PkgRemoveStart, PkgBuild, PkgSign, PkgKeygen, ...)
+ json.bas JSON: read a document (a web service's reply, a settings file) and find values in it by path such as packages[3].version, and quote text for JSON of your own (JsonParse, JsonText$, JsonNumber, JsonCount, JsonKey$, JsonQuote$, ...)
+ http.bas Web requests: GET a page, a file, or a web service's answer, or POST to one, over the network a little at a time with progress, to memory or to a file (resuming one left part-way), following redirects (HttpOpen, HttpStart, HttpRunFor, HttpStatus, HttpBody$, HttpSaveTo, ...)
+ pkgnet.bas A package server: what it offers, what it has that is newer than what is installed, and fetching packages with what they need, for pkg.bas to install (PkgNetOpen, PkgNetCatalogue, PkgNetUpdates, PkgNetFetch, PkgNetRunFor, PkgNetRows$, ...)
Using an Include Library
' With commdlg.bas added to the project
Dim path As String
@@ -5104,7 +5148,7 @@ End Sub
Paths: FileOpsPathNormalize$ (the full path of an existing folder, or ""), FileOpsPathParent$, FileOpsPathJoin$, FileOpsPathSame, FileOpsPathIsRoot, FileOpsPathIsAbsolute (True for a path that is complete by itself, such as "C:\DOCS", False for one that counts from the current folder), FileOpsPathIsInside(path, folder) (True when path is folder itself or anywhere inside it: check it before copying or moving a folder, so it is never copied into itself), FileOpsPathSep$ (the character between folder names, "\" on DOS), FileOpsPathNative$ (a path written with that character, for showing it: "C:/DOCS/A.TXT" becomes "C:\DOCS\A.TXT"), FileOpsBaseName$, and FileOpsExtension$. Every one of them takes / and \ alike and gives back \. FileOpsFilterMatch(itemName, filter) tells whether a name matches a filter the way the file dialogs match it: wildcards * and ?, several patterns separated by ; such as "*.bmp;*.png", case not mattering.
Files: FileOpsMakeDir and FileOpsRename (FileOpsError$ says why one failed), FileOpsTempPath$ and FileOpsReplaceFile (save safely: write the new file to FileOpsTempPath$(path$), then FileOpsReplaceFile swaps it in; the old file comes back if that fails), FileOpsValidName(folder, itemName) (whether a name can be used for a new file or folder in folder, by the rules of its disk; FileOpsError$ says why not), FileOpsValidLongName and FileOpsValidShortName (the long-name or the 8.3 rules by themselves, whatever the disk takes), FileOpsShortPath$ (the short 8.3 form of an existing path, for an old program that cannot take long names or blanks), FileOpsMakePath (a folder and every missing folder above it, which MKDIR does not make), FileOpsIsFolder, FileOpsGetAttr, and FileOpsSetAttr (the FILEOPS_ATTR_ bits), FileOpsModified$ and FileOpsSetModified (when a file was last changed, read and set in the form "YYYY-MM-DD HH:MM"; FileOpsSetModified returns False for a date that does not exist), FileOpsTreeSize with FileOpsTreeBytes, FileOpsTreeFiles, and FileOpsTreeFolders (the size of a whole folder), and FileOpsFormatSize$.
Drives: FileOpsDriveList$ (the drive letters), FileOpsDriveType (FILEOPS_DRIVE_FIXED for a hard disk, FILEOPS_DRIVE_REMOVABLE for a floppy disk drive, FILEOPS_DRIVE_REMOTE for a network drive or a CD-ROM; asking never makes a drive spin up), FileOpsBootDrive$ (the drive the computer started from), FileOpsFreeBytes, FileOpsFreeKb, and FileOpsTotalBytes (the size of the disk; right for disks past 2 GB too), FileOpsLongNames(path) (True when the disk holding path takes long file names: up to 255 characters, with spaces and several dots; otherwise names are 8.3). FileOpsIsWritable(folder) is False where files cannot be made (a CD-ROM, a write-protected floppy), and FileOpsReadOnlySystem() is True when nothing can be saved: DVX runs from such a disk and there is no TEMP folder to keep the user folder in. FileOpsTempDir$() is the folder for scratch files ("" when there is none).
-
DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, and desktop pictures come in two folders each, DVX's own in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, and FILEOPS_KIND_WPAPER. FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
+
DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, and MIDI instrument banks come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, and FILEOPS_KIND_BANKS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
FileOpsCopyTree, FileOpsMoveTree, and FileOpsDeleteTree do a whole folder at once. For a long copy, move, or delete the program stays usable when it runs the work as a job, a little at a time from a Timer: FileOpsJobCreate, then FileOpsJobAddSource for each file or folder, then FileOpsJobRunFor from the timer until it returns FILEOPS_STEP_DONE or FILEOPS_STEP_CANCELLED. When it returns FILEOPS_STEP_ASK (a file already exists, a file is read-only) or FILEOPS_STEP_ERROR, show FileOpsJobMessage$ and pass the user's choice to FileOpsJobAnswer. FileOpsJobCurrent$, FileOpsJobFilesDone, FileOpsJobFilesTotal, FileOpsJobPercent, FileOpsJobBytesDone, and FileOpsJobBytesTotal show the progress, and FileOpsJobCancel stops the job. Files copied from a CD-ROM are read-only; FileOpsJobClearAttr job, FILEOPS_ATTR_READONLY before the job runs makes every copy changeable (FILEOPS_ATTR_EDITABLE clears all four attribute bits). Whatever a program leaves open is freed when it ends.
Dim job As Long
@@ -5189,6 +5233,57 @@ End Sub
id = HotkeyNext()
Loop
End Sub
+
Add-on Packages
+
With pkg.bas in the project, a program installs, removes, and makes add-on packages -- the .DPK files Software installs -- the way Software and Package Builder (both written in BASIC) do. PkgOpen() starts a session (a number; PkgClose ends it), and PkgError$(session) says why the last call that failed did. Lists come back as rows for a ListView's SetRows: cells separated by TAB, each row ending in a line feed.
+
What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it.
+
Installing. PkgOfferFile(s, path) reads a package file and every package beside it (what it may need) as offers, numbered from 0, and returns the number of the one at path (-1 when it cannot be read). PkgOfferField$ and PkgOfferFilesRows$ describe an offer before it is installed; PKG_FIELD_TRUST says whether DVX trusts who signed it. PkgPlan(s, "0") plans installing it with everything it needs, checked against this DVX and the packages installed (False, with PkgError$ saying why, when it cannot be installed); PkgPlanRows$ lists what will go in, PkgPlanBytes the disk space, and PkgPlanRestart whether libraries or widgets are among them (they work from the next time DVX starts). PkgInstallStart(s) then starts the job, and PkgRunFor(s, milliseconds) runs it a little at a time, returning PKG_RUN_BUSY until it is PKG_RUN_DONE or PKG_RUN_FAILED (PkgRunMessage$ says why); PkgRunPercent and PkgRunCurrent$ show how far it is.
+
offer = PkgOfferFile(s, path)
+If offer >= 0 And PkgPlan(s, CStr(offer)) Then
+ If PkgInstallStart(s) Then
+ Do
+ rc = PkgRunFor(s, 60)
+ pbJob.Value = PkgRunPercent(s)
+ DoEvents
+ Loop While rc = PKG_RUN_BUSY
+ End If
+End If
+
Removing. PkgDependents$(s, ids) lists the installed packages that need the ones going (they must go too), and PkgOrphans$(s, ids) those only they needed. PkgRemoveStart(s, ids, removeChanged) starts removing, run with PkgRunFor like an install; files changed since they were installed stay unless removeChanged, and PkgRunKept$ lists them.
+
Trust. A publisher signs a package with a key so people can tell it came from them. PkgTrustKey(s, keyPath) trusts a publisher's key file (.KEY) from then on.
+
From a package server. With pkgnet.bas in the project too, a program gets packages from a package server the way Software does. PkgNetOpen("") returns a handle on the server DVX's settings name, with the account they give; PkgNetClose frees it. PkgNetServer$, PkgNetPort, PkgNetUser$, and PkgNetPassword$ read those settings (PkgNetUrl$ is the address they make), and PkgNetSaveSettings(server, port, user, password) changes them. A job is started with PkgNetCatalogue (every package the server offers), PkgNetUpdates (what it has that is newer than the packages installed, DVX itself among them), or PkgNetFetch(n, ids) (packages by id, with everything they need that is not installed), and run with PkgNetRunFor(n, milliseconds) until it is PKGNET_DONE or PKGNET_FAILED (PkgNetError$ says why); PkgNetPercent and PkgNetCurrent$ show how far a fetch is. PkgNetRows$ lists the answer (id, name, version, kind, summary, size, and the version installed, if any), and PkgNetField$ and PkgNetDependsRows$ describe one package in it. Each package fetched is checked against the size and fingerprint the server gives, and the server's signature of it comes with it, so it is trusted or not exactly as a package file is. What was fetched is in PkgNetDir$: PkgOfferFolder(s, folder) reads every package there as the offers, and PkgOfferIndex(s, id) gives the number of the one wanted, to plan and install as above; PkgNetClear removes them afterwards. With an account (the settings', or PkgNetAccount(n, email, password) for another server), PkgNetUpload(n, path) publishes a package file for the server's review, and PkgNetMine(n) asks what the account has sent: PkgNetMineRows$ lists it (id, name, version, state, and the review's note); both sign in first.
+
n = PkgNetOpen("")
+If PkgNetFetch(n, "acme.chess") Then
+ Do
+ rc = PkgNetRunFor(n, 60)
+ pbJob.Value = PkgNetPercent(n)
+ DoEvents
+ Loop While rc = PKGNET_BUSY
+End If
+If rc = PKGNET_DONE And PkgOfferFolder(s, PkgNetDir$(n)) > 0 Then
+ offer = PkgOfferIndex(s, "acme.chess")
+ ' plan and install offer as above, then PkgNetClear n
+End If
+
Making packages. PkgBuild(s, template, files, out, secret) builds a package: template is a settings file with a [package] section (id, name, version, kind, and summary, publisher, author, licence, homepage, dvx as wanted) and a [depends] section (id = oldest version for each package it needs); files are rows of a name in the package (from the DVX folder, such as APPS\ACME\CHESS\CHESS.APP) and the file it is made from; secret is the secret key file (.SEC) to sign with, or "". PkgFolderRows$(s, folder, prefix) gives those rows for every file in a folder, and PkgKindFolder$(kind, id) the folder a package of that kind keeps its files in. PkgSign signs a package already built, PkgKeygen(s, name, base) makes a key pair (base.KEY to give out, base.SEC to keep private), and PkgKeyInfo$ reads a key file's name and id. PkgIdValid and PkgVersionValid check an id and a version before building; PkgKindName$ lists the kinds.
+
Web Requests and JSON
+
With http.bas in the project, a program asks web servers for pages, files, and web services' answers. HttpOpen(method, url) makes a request ("GET" to fetch, "POST" to send) and returns a handle; HttpClose frees it. Before starting it, HttpAddHeader adds a header, HttpSetBody gives a body to send with its type ("application/json", say), HttpSaveTo writes the answer's body to a file instead of keeping it (with keepPart True, a file left part-way is finished rather than started again), and HttpSetTimeout sets how many seconds without progress mean the server is not answering (30 to start with). HttpStart starts it (False for an address that is not http://; there is no encryption), and HttpRunFor(h, milliseconds) runs it a little at a time, returning HTTP_BUSY until it is HTTP_DONE or HTTP_FAILED:
+
h = HttpOpen("GET", "http://example.com/news.txt")
+IF HttpStart(h) THEN
+ DO
+ rc = HttpRunFor(h, 60)
+ lblProgress.Caption = CStr(HttpBytes(h)) & " bytes"
+ DoEvents
+ LOOP WHILE rc = HTTP_BUSY
+END IF
+IF rc = HTTP_DONE AND HttpStatus(h) = 200 THEN txtNews.Text = HttpBody$(h)
+IF rc = HTTP_FAILED THEN MsgBox HttpError$(h)
+HttpClose h
+
HTTP_DONE means the server answered: HttpStatus is how (200 OK, 404 not found, ...), HttpBody$ what it sent, HttpReplyHeader$ one of its headers, and HttpUrl$ where the answer came from when the server sent the request elsewhere (redirects are followed). HTTP_FAILED means no answer came, and HttpError$ says why. HttpBytes and HttpTotal (-1 until the server says) show progress.
+
With json.bas in the project, JsonParse(text) reads JSON -- what most web services answer with -- and returns a handle (0 when it is not JSON: JsonError$ says why and on which line); JsonFree frees it. The other functions find a value by its path from the top: member names and item numbers from 0, such as "packages[3].version". JsonType says what is there (JSON_STRING, JSON_NUMBER, JSON_ARRAY, ..., JSON_NONE for nothing), JsonText$ gives a string, JsonNumber a number, JsonBool True or False, JsonCount how many items an array or members an object has, and JsonKey$(h, path, i) the name of an object's member i. JsonQuote$(text) puts text in quotes with its special characters escaped, for building JSON to send:
+
h = JsonParse(HttpBody$(req))
+FOR i = 0 TO JsonCount(h, "packages") - 1
+ lstPackages.AddItem JsonText$(h, "packages[" & CStr(i) & "].name")
+NEXT i
+JsonFree h
+body = "{" & JsonQuote$("name") & ": " & JsonQuote$(txtName.Text) & "}"
Changing DVX's Settings
With system.bas in the project, a program changes what Control Panel changes, at once for the whole desktop. Add prefs.bas too: the settings are saved through a prefs.bas handle.
Screen mode: SysVideoModeCount() says how many modes the display offers (numbered from 0), and SysVideoModeWidth, SysVideoModeHeight, and SysVideoModeBpp (bits per pixel) describe each. SysSetVideoMode(wide, high, bpp) changes the mode and keeps every window; it returns False when the mode could not be set (the old one stays), also while a program has the whole screen to itself. A mode the monitor cannot show leaves the user unable to answer, so do as Control Panel does: ask "Keep this mode?" and go back to the old mode by itself after some seconds. Screen.Width, Screen.Height, and Screen.BitsPerPixel tell the mode in use, and SYS_VIDEO_DEFAULT_WIDTH, SYS_VIDEO_DEFAULT_HEIGHT, and SYS_VIDEO_DEFAULT_BPP the mode DVX starts in when its settings name none. SysScreenshot(path) saves the whole screen as a picture file (BMP when path ends in .bmp, PNG otherwise) and returns True when it was written.
@@ -5579,6 +5674,7 @@ SecDhFree dh
Handles a program leaves open are freed, and their keys wiped, when it ends.
Settings Files
With prefs.bas in the project, a program keeps its settings in an INI file the way DVX's own programs do. PrefsLoad(path) reads the file once (a file that is not there reads as empty); PrefsGet$, PrefsGetInt, and PrefsGetBool read a key of a section (each with the value to use when it is missing), and PrefsSet, PrefsSetInt, PrefsSetBool, and PrefsRemove change it in memory. PrefsSave writes it back, keeping comments and every line it did not change. To write a whole new file -- dropping sections that are no longer wanted -- fill a PrefsCreate handle and save it with PrefsSaveAs: the old file is replaced only once the new one is completely written. PrefsClose frees a handle. IniRead and IniWrite (built in) do one value at a time and read or write the whole file each time; prefs.bas is the faster choice for many settings.
+
A password is better kept with PrefsSetSecret handle, section, key, value and read back with PrefsGetSecret$(handle, section, key) ("" when it is missing). The file then holds a "*" and a row of letters and digits that change each time it is saved, not the password itself, so it cannot be read over someone's shoulder. This scrambles the password; it does not lock it away: anyone who knows how DVX scrambles it can unscramble it, so treat the file with the same care as the password. A value typed into the file by hand, without the "*", is read as it is.
Dim ini As Long
ini = PrefsLoad(App.User & "\MYAPP.INI")
userName = PrefsGet$(ini, "User", "Name", "Unknown")
@@ -7189,7 +7285,7 @@ End Sub
------ -----------
Clear Remove all the text. The font, style, and color go back to the control's own.
SetFont family, size The font family (for example "DejaVu Serif") and size in pixels of the text added next. "" and 0 go back to the control's own font and size.
- SetStyle style FONT_BOLD, FONT_ITALIC, FONT_UNDERLINE, and FONT_STRIKE added together (from font.bas; 0 for plain) for the text added next.
+ SetStyle style FONT_BOLD, FONT_ITALIC, FONT_UNDERLINE, and FONT_STRIKE added together (from font.bas; FONT_REGULAR for none) for the text added next.
SetColor color The color of the text added next, as RGB() makes it; -1 for the control's ForeColor.
AddText text Add text in the font, style, and color chosen.
AddLineBreak Start a new line in the same paragraph.
diff --git a/docs/dvx_system_reference.html b/docs/dvx_system_reference.html
index 02d8a186..1ccfcb3c 100644
--- a/docs/dvx_system_reference.html
+++ b/docs/dvx_system_reference.html
@@ -46,6 +46,7 @@ img { max-width: 100%; }
Double-click a folder to look inside it. Click a file to put its name in the File name box, then click Open, or just double-click the file. If you type the name of a file that does not exist, the dialog asks whether you want to create it.
Saving a File
Go to the folder you want, type a name in the File name box, and click Save. If you leave off the ending (such as .txt), the dialog adds the one from the Filter box. If a file with that name already exists, DVX asks whether to replace it.
+
Choosing a Folder
+
Where a program needs a folder rather than a file, the Browse... button beside it opens the Choose a Folder dialog. It works like the Open dialog but lists folders only. Go into the folder you want and click OK. To choose a new folder, type its name in the Folder name box and click OK: the program makes it when it needs it.
Tip: Alt+D opens the Drive list from the keyboard. You can also change drives by typing the drive letter and a colon, such as A:, in the Path box and pressing Enter.
@@ -2237,7 +2294,7 @@ img { max-width: 100%; }
Printing
Programs that can print have Print... in their File menu, and programs that print text also have Page Setup.... Every program prints to the same printer, so you only tell DVX once which printer you have and where it is plugged in (see Changing Settings). You can also send printouts into a file instead of a printer.
The Print Dialog
-
File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print.
+
File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.
While a printout is going to the printer, a small Printing window shows it is busy. You can keep working in other programs, and Cancel stops the printout.
When the Printer Has a Problem
If the printer is out of paper, switched off, off line, or not answering, a message says what is wrong. Fix the problem and choose Retry to carry on, or Cancel to stop printing. Nothing waits forever.
@@ -2249,7 +2306,7 @@ img { max-width: 100%; }
Running Programs
Finding and Starting a Program
-
Every program installed in DVX appears as an icon in the Program Manager, in its Installed Programs group, and you can put icons for the programs you use most into groups of your own. Select an icon to see a short description of the program in the Program Manager's status bar.
+
Every program installed in DVX appears as an icon in the Program Manager, in its Installed Programs group, and you can put icons for the programs you use most into groups of your own. Programs you add later come as packages you install with the Software program (see Adding and Removing Software). Select an icon to see a short description of the program in the Program Manager's status bar.
Double-click an icon to start that program, or select it with the arrow keys and press Enter. The program opens in a new window. While it is loading, the mouse pointer shows an hourglass. The Program Manager's Search box finds a program in every group as you type its name.
To start a program file that has no icon, choose Run... from the Program Manager's File menu and type or pick the file.
Opening Documents
@@ -2277,6 +2334,7 @@ img { max-width: 100%; }
If a program has no help of its own, F1 opens this guide instead.
You can make your own groups, add, move, and copy icons, give an icon a shortcut key that starts it from any program, and choose large icons, small icons, or a list. Everything you arrange is remembered the next time you start DVX. The Program Manager's own help explains all of this: press F1 while it is the active window, or choose Contents from its Help menu.
Warning: Closing the Program Manager window leaves DVX, after asking you first.
+
+
Adding and Removing Software
+
Programs, fonts, sounds, themes, desktop pictures, MIDI instruments, and the libraries and widgets other programs use can be added to DVX after it is installed. Each comes as a package: one file whose name ends in .DPK.
+
Installing a Package
+
Open the .DPK file in File Manager, or start the Software program and choose Install from File. Software shows what will be installed first -- including other packages it needs from the same folder -- who signed it, and how much disk space it takes, then installs it when you choose Install. A new program appears in the Program Manager's Installed Programs group; new fonts, sounds, themes, desktop pictures, and instruments appear where you choose them in the settings program. Libraries and widgets work from the next time DVX starts.
+
Software can also get packages from the package server over the network: its Available page lists every package the server offers. Choose one and Install; Software fetches it with any packages it needs, then shows what will be installed exactly as for a file.
+
A package can be signed by the people who made it, like a seal on a letter. Software tells you when a package is not signed by anyone you trust: install it only if you trust where it came from. To trust a publisher, get their key file (it ends in .KEY) and choose Trust a Publisher's Key in Software's File menu.
+
Keeping DVX Up to Date
+
Software's Updates page asks the package server what is newer than what you have, DVX itself included, and Update All installs it. When DVX itself is updated, the new version runs from the next time DVX starts; Software lists any add-ons that will stop working with it until they are updated too. The package server's address and port are set with Package Server in Software's File menu.
+
Publishing Your Own Packages
+
With an account on the package server (its email and password given in the same Package Server window), Software's File > Publish a Package sends a package you made to the server. It is offered to everyone once the server's operator has reviewed it; Software's Published page shows how the review went.
+
Removing a Package
+
Software's Installed list shows every package in DVX. Choose one and Remove. If other packages need it, Software names them and removes them too (or nothing); files you changed since they were installed are kept unless you say otherwise.
+
Making Your Own Packages
+
Package Builder makes a package of your own program, fonts, or other files, for other people to install with Software, and signs it with a key of yours. The DVX BASIC IDE makes a package of a BASIC program in one step: File > Make Package. Their help explains how.
+
If DVX Will Not Start
+
If DVX does not start after you installed something, hold down a Shift key while DVX starts. DVX then starts without the libraries and widgets add-ons installed, so you can remove the one that causes the trouble with Software. See Troubleshooting.
The mouse, the screen colors, the desktop picture, the screen resolution, which program opens each kind of document, the network, the printer, joysticks, and the sounds are changed with the settings program, which has an icon in the Installed Programs group of the Program Manager. Start it like any other program. Its own help, opened with F1 or its Help menu, explains every setting.
@@ -2378,15 +2456,19 @@ img { max-width: 100%; }
Safe video mode
If the screen resolution DVX is set to use does not work on your computer (for example after changing video cards), DVX starts in safe video mode instead: 640x480, which every video card can show. A message tells you so and asks whether to use this mode from now on. Answer Yes to keep it, or No to try the old resolution again at the next start.
If DVX starts but the screen cannot be read (the monitor cannot show the saved resolution), hold down a Shift key while DVX starts. DVX then starts in safe video mode and asks the same question. Typing DVX /SAFE at the DOS prompt does the same.
+
Starting this way is a safe start: DVX also leaves out the libraries and widgets that add-on packages installed, in case one of them is what stops DVX from starting. A message says so once the desktop is up. Start DVX the usual way to use them again, or remove the add-on that causes the trouble with the Software program.
+
Add-ons that could not be loaded
+
When an add-on package's library or widget cannot be loaded (it was made for another version of DVX, it needs something that is not installed, or its file is damaged), DVX starts without it and a message once the desktop is up lists what was left out and why. The rest of DVX works as usual. Reinstall the add-on (a version made for this DVX), or remove it with the Software program.
You can pick another resolution at any time with the settings program (see Changing Settings).
There is no sound
DVX finds the sound hardware by itself. A Sound Blaster needs its BLASTER setting in AUTOEXEC.BAT, for example SET BLASTER=A220 I5 D1 H5 (the card's manual gives the numbers). Without a sound card DVX uses the PC speaker, which plays tones and beeps but not recorded sounds. The settings program shows the sound hardware in use, lets you pick it yourself, and sets the volume; if a sound is set to none there, nothing is heard for it. Without any sound hardware DVX and its programs work the same, silently.
An empty floppy or CD-ROM drive
When a program tries to read a drive that has no disk in it, or to write to a write-protected disk, the program simply reports that it could not read or write the file. DVX never stops at the DOS "Abort, Retry, Fail" question. Put the disk in and try again.
Installing, upgrading, and removing DVX
-
DVX is installed with SETUP, from the DVX CD or from Disk 1 of the DVX floppy disks: change to that drive and type SETUP. Setup shows the licence, asks for the folder to install DVX in and which fonts to install, checks that there is room, and unpacks DVX there, asking for each floppy disk it needs in turn. It can also make DVX start when the computer starts (it shows the line it adds to AUTOEXEC.BAT and keeps a copy of the old file as AUTOEXEC.DVX). Setup never writes to the CD or the floppy disks.
+
DVX is installed with SETUP, from the DVX CD or from Disk 1 of the DVX floppy disks: change to that drive and type SETUP. After the licence, Setup asks what you would like to do: Install DVX, Upgrade or repair DVX, or Remove DVX. Click the picture or the button of your choice. To install, choose the folder for DVX (type it, or click Browse... to pick it; a folder that does not exist yet is made) and which fonts to install. Setup checks that there is room and unpacks DVX there, asking for each floppy disk it needs in turn. It can also make DVX start when the computer starts (it shows the line it adds to AUTOEXEC.BAT and keeps a copy of the old file as AUTOEXEC.DVX). Setup never writes to the CD or the floppy disks.
The fonts are up to you. None keeps DVX to its own font; the base set adds DejaVu Sans, Sans Mono, and Serif in all their styles (about 2 MB); and All adds every font that comes with DVX (about 8 MB). A program that asks for a font you did not install shows its text in DVX's own font instead, unless it carries that font itself (some programs do, such as Welcome and the DVX Demo). Every font file holds its own licence: the Licence button of the Font dialog shows it. With None or the base set, Setup does not ask for the last floppy disks, which hold only fonts.
-
To upgrade or repair DVX, run Setup again and choose the same folder: answer Yes to keep your settings and saved files (the USER folder), or No to remove them and start over. Setup starts with the fonts that folder already has; choose others to add fonts or remove them. To remove DVX, run SETUP in the DVX folder (for example C:\DVX\SETUP) and choose Uninstall. Files you added to the folder yourself are not removed, and the USER folder is only removed if you ask.
+
To upgrade or repair DVX, run Setup again and choose Upgrade or repair DVX, with the folder DVX is in. (When DVX is in the usual folder, such as C:\DVX, Setup says so and suggests this choice.) Your settings and saved files (the USER folder) are kept, unless you tick Start over to remove them. Setup starts with the fonts that folder already has; choose others to add fonts or remove them. If you choose Install DVX for a folder that already has DVX, Setup offers to upgrade it instead.
+
To remove DVX, run Setup and choose Remove DVX, with the folder DVX is in. The SETUP in the DVX folder (for example C:\DVX\SETUP) can remove DVX too, from its own folder only. Files you added to the folder yourself are not removed, and the USER folder is only removed if you ask.
Running DVX from a CD-ROM or a write-protected disk
DVX also runs straight from a disk it cannot write to. It then keeps your settings and files in a DVXUSER folder in your TEMP folder, and tells you so once. To keep them somewhere of your choosing, set DVXUSER to a folder on your hard disk before starting DVX (see Changing Settings). Without a TEMP folder, DVX tells you that changed settings last only until you exit DVX. Either way the help files are used as they are. To keep everything on your hard disk, install DVX there with Setup.
A program is already running
@@ -5964,21 +6046,23 @@ int32_t pick = dvxButtonBox(ctx, "Copy", "The disk is full."
Define Value Description
------ ----- -----------
FD_OPEN 0x0000 File open dialog (default)
- FD_SAVE 0x0001 File save dialog (with overwrite prompt)
+ FD_SAVE 0x0001 File save dialog (with overwrite prompt)
+ FD_FOLDER 0x0002 Choose a folder, new or existing
Display a modal file open/save dialog. Shows a directory listing with parent/drive-letter navigation, a drive dropdown, a filename input, and an optional filter dropdown. The path box always shows the current folder, and the drive dropdown (platformDriveList, so listing the drives never touches empty floppy or CD-ROM drives) its drive; choosing a drive goes to its root, and a drive that cannot be read shows "Drive X: is not ready." and leaves the dialog where it was. Typing "D:" or "D:folder" in the path box changes drive/folder; the file name box also accepts a relative or absolute path ("sub\x.txt", "C:\X\A.TXT") and navigates to its folder. A folder that does not exist shows an "Invalid Path" error. The dialog closes at once if the app that owns it is ended.
+
With FD_FOLDER the dialog chooses a folder: the list shows folders only (filters are not used), the name box is "Folder name", and OK returns the folder shown, or the folder of the typed name in it (or the typed path itself when it is absolute). That folder need not exist yet; the caller makes it (platformMkdirRecursive). A name that is a file shows an "Invalid Folder" error, and so does a new name the disk cannot hold. An initialDir that does not exist yet opens at its nearest existing folder with the rest already in the name box, so OK alone returns initialDir unchanged. The default title is "Choose a Folder".
Parameter Description
--------- -----------
ctx Application context
- title Window title
- flags FD_OPEN or FD_SAVE
+ title Window title (NULL = "Open", "Save As", or "Choose a Folder")
+ flags FD_OPEN, FD_SAVE, or FD_FOLDER
initialDir Starting directory (NULL = current working directory)
filters Array of FileFilterT, or NULL
filterCount Number of filter entries
outPath Output buffer for selected path
outPathSize Capacity of outPath buffer
-
Returns: true if the user selected a file, false if cancelled or closed.
+
Returns: true if the user selected a file (or folder), false if cancelled or closed.
@@ -6193,7 +6277,7 @@ if (dvxFontDialog(ctx, NULL, 0, &choice)) {
dvxPrefs.h -- INI Preferences System
Handle-based API over classic INI files. Each prefsLoad/prefsCreate returns a PrefsHandleT that must be passed to all subsequent calls and freed with prefsClose when done. Multiple INI files can be open simultaneously.
-
BASIC programs reach the same functions through prefs.bas (dvxPrefsBas.c in libdvx): PrefsLoad, PrefsCreate, PrefsGet$, PrefsGetInt, PrefsGetBool, PrefsSet, PrefsSetInt, PrefsSetBool, PrefsRemove, PrefsKeyName$ (prefsKeyName, "" past the last key), PrefsSave, PrefsSaveAs, and PrefsClose, on handles (1, 2, ...; 0 = none) that belong to the program that made them and are closed, unsaved, when it ends. True is -1, False 0. PrefsDvxIni$ is platformDvxIniPath() (the user folder's DVX.INI), PrefsSetVideoMode is prefsSetVideoMode, and PrefsVideoWidth / PrefsVideoHeight / PrefsVideoBpp are the three values of prefsGetVideoMode (DVX_DEFAULT_VIDEO_* for a missing key or a bad handle). The choice tables below are PREFS_CHOICE_ACCEL (0), PREFS_CHOICE_WALLPAPER (1), and PREFS_CHOICE_WHEEL (2) there: PrefsChoiceCount, PrefsChoiceLabel$, PrefsChoiceName$ (iniName), PrefsChoiceValue, PrefsChoiceIndex, PrefsChoiceIndexOfValue, PrefsChoiceDefault (the index of the choice MOUSE_ACCEL_DEFAULT / WALLPAPER_MODE_DEFAULT / MOUSE_WHEEL_DIR_DEFAULT names; -1 for an unknown table), and PrefsGetChoice(handle, section, key, table) with the table's default. PrefsGetChoiceFrom(handle, section, key, choiceNames, defaultName) is prefsGetChoice on a table the program gives as "|"-separated names (each choice's iniName and label is its name, its value its index).
+
BASIC programs reach the same functions through prefs.bas (dvxPrefsBas.c in libdvx): PrefsLoad, PrefsCreate, PrefsGet$, PrefsGetInt, PrefsGetBool, PrefsSet, PrefsSetInt, PrefsSetBool, PrefsGetSecret$, PrefsSetSecret, PrefsRemove, PrefsKeyName$ (prefsKeyName, "" past the last key), PrefsSave, PrefsSaveAs, and PrefsClose, on handles (1, 2, ...; 0 = none) that belong to the program that made them and are closed, unsaved, when it ends. True is -1, False 0. PrefsDvxIni$ is platformDvxIniPath() (the user folder's DVX.INI), PrefsSetVideoMode is prefsSetVideoMode, and PrefsVideoWidth / PrefsVideoHeight / PrefsVideoBpp are the three values of prefsGetVideoMode (DVX_DEFAULT_VIDEO_* for a missing key or a bad handle). The choice tables below are PREFS_CHOICE_ACCEL (0), PREFS_CHOICE_WALLPAPER (1), and PREFS_CHOICE_WHEEL (2) there: PrefsChoiceCount, PrefsChoiceLabel$, PrefsChoiceName$ (iniName), PrefsChoiceValue, PrefsChoiceIndex, PrefsChoiceIndexOfValue, PrefsChoiceDefault (the index of the choice MOUSE_ACCEL_DEFAULT / WALLPAPER_MODE_DEFAULT / MOUSE_WHEEL_DIR_DEFAULT names; -1 for an unknown table), and PrefsGetChoice(handle, section, key, table) with the table's default. PrefsGetChoiceFrom(handle, section, key, choiceNames, defaultName) is prefsGetChoice on a table the program gives as "|"-separated names (each choice's iniName and label is its name, its value its index).
PrefsHandleT *prefsBasHandle(int32_t handle);
The settings behind a prefs.bas handle, NULL for 0 or a handle that is not open. Other libraries' BASIC entry points that take a prefs.bas handle (system.bas, joystick.bas, net.bas, print.bas, sound.bas) resolve it with this; libdvx is loaded first, so the symbol is always there.
The system INI
@@ -6273,6 +6357,16 @@ if (dvxFontDialog(ctx, NULL, 0, &choice)) {
key Key name
defaultVal Returned if the key is absent or unrecognised
Read back a secret prefsSetSecret stored, into out (cut to outSize - 1 characters and always terminated; nothing is written when outSize is 0 or less). A missing key, or a NULL handle, gives "". A value prefsSetSecret did not write (one typed into the file by hand: no leading "*", or not hex digits after it) is copied as it is, so a password can be put in the file by hand and is scrambled the next time the program saves it.
+
Parameter Description
+ --------- -----------
+ h Preferences handle
+ section Section name
+ key Key name
+ out Receives the text
+ outSize Size of out in bytes
Store a secret, such as a password, so it cannot be read at a glance: "*", a 32-bit salt as 8 hex digits, then each byte of the text XORed with an xorshift32 keystream started from the salt, as 2 hex digits. Each call takes a new salt (clock(), time(), and a call count), so the same text is stored differently each time. This is obfuscation, not encryption: anyone with this code can read the value back, so the file still needs the care of any file holding a password. An empty or NULL value is stored as "". prefsGetSecret reads it back.
Write path with its file-name extension replaced by ext into out, followed by as many leading characters of the original extension as fit in PLATFORM_SIBLING_EXT_MAX characters (a full-length ext such as ".BAK" replaces the extension outright; a name with no extension gets ext appended). Replacing rather than appending keeps an 8.3 name valid on DOS without long file names, and keeping part of the original extension gives files that differ only in extension distinct scratch names: USER\DVX.INI becomes USER\DVX.$IN (temp) and USER\DVX.~IN (backup), FOO.APP and FOO.HLP become FOO.$AP and FOO.$HL. Returns false when the result does not fit in outSize or would name path itself (compared case-insensitively).
Crash-safe swap of a fully written tmpPath in for path. The original is renamed aside to bakPath first (DOS rename cannot overwrite) and removed only once the replacement is in place; if the swap fails the original is restored and tmpPath removed. A directory at path is never moved aside: the call fails and removes tmpPath. A leftover bakPath is removed only while path exists; with path missing it may be the only copy left by a crash between the two renames, so it is kept (prefsLoad reads it back). The preferences system, the resource writer, and the help compiler all save this way, with the PLATFORM_TMP_EXT and PLATFORM_BAK_EXT siblings from platformPathSibling.
+
Crash-safe swap of a fully written tmpPath in for path. The original is renamed aside to bakPath first (DOS rename cannot overwrite) and removed only once the replacement is in place; if the swap fails the original is restored and tmpPath removed. A directory at path is never moved aside: the call fails and removes tmpPath. A leftover bakPath is removed only while path exists; with path missing it may be the only copy left by a crash between the two renames, so it is kept (prefsLoad reads it back). The resource writer and the help compiler save this way, with the PLATFORM_TMP_EXT and PLATFORM_BAK_EXT siblings from platformPathSibling.
Write len bytes of data as the whole of path, all or nothing: into the PLATFORM_TMP_EXT sibling, then swapped in with platformReplaceFile, so a full disk or an I/O error leaves the file as it was. False when it was not done. The preferences system, the IDE's saves, Setup, and the package records use it.
Slurp a whole file into a freshly malloc'd, NUL-terminated buffer. Caller frees. Binary-safe: the NUL is past the end of the reported length.
@@ -6829,12 +6929,15 @@ char platformBootDrive(void);
#define DVX_SOUNDS_DIR "SOUNDS"
#define DVX_THEMES_DIR "THEMES"
#define DVX_WPAPER_DIR "WPAPER"
+#define DVX_BANKS_DIR "BANKS"
+#define DVX_VENDOR_DIR "KPUNCH"
typedef enum {
DvxKindFontsE,
DvxKindSoundsE,
DvxKindThemesE,
DvxKindWpaperE,
+ DvxKindBanksE,
DvxKindCountE
} DvxKindE;
@@ -6857,12 +6960,12 @@ char **platformKindReadDir(DvxKindE kind);
SYSTEM DVX's own tools and files, and the shipped DVX.INI and ASSOC.INI
LIBS, WIDGETS The libraries and widgets
APPS\<vendor>\<program> A program, its help, and DATA (the files it ships; read-only)
- SHARED FONTS, SOUNDS, THEMES, and WPAPER: what DVX ships for every program
- USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, and WPAPER, APPS\<program path> (each program's settings and saved files), and DOWNLOAD
-
Only DVX's own package puts files in SHARED. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.
+ SHARED FONTS, SOUNDS, THEMES, WPAPER, and BANKS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME)
+ USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, and BANKS, KEYS (publishers' keys the user trusts), APPS\<program path> (each program's settings and saved files), and DOWNLOAD
+
Only packages put files in SHARED: DVX's own, and add-on packages (pkg.lib), each in its vendor's folders. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.
platformUserInit, called once by the loader, decides the user folder: the folder the DVXUSER environment variable names, else USER in the DVX folder, made when missing. When it cannot be written (DVX on a CD-ROM without DVXUSER), DVXUSER in the TEMP folder is used instead and platformUserInTemp is true; without a TEMP folder nothing can be saved and platformIsReadOnly is true. The first start copies SYSTEM\DVX.INI to the user folder. platformUserDir is the folder (DVXUSER or USER before platformUserInit has decided); platformUserPath joins a name to it; platformDvxIniPath is its DVX.INI. platformUserDirOf is the user folder of a DVX installed in another folder (DVXUSER, else that folder's USER), for Setup.
platformAppUserDir is a program's folder in the user folder: APPS\KPUNCH\TERMINAL\TERMINAL.APP gives USER\APPS\KPUNCH\TERMINAL; a program outside the DVX folder gives USER\APPS\<its file name without the extension>. It is not made here (the shell's userDir and BASIC's App.User are made on first use).
-
SHARED and the user folder have the same four kinds of folder (DvxKindE). platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name, the user's first, so a user's file of the same name wins; a name with a folder in it is taken as the path it is. platformKindNameOf is the other way: a file directly in one of the kind's folders gives its name alone, which is what settings store (DVX.INI [sounds] and [desktop] wallpaper), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them; free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas), and App.User for a program's own folder.
+
SHARED and the user folder have the same five kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, and MIDI instrument banks. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_BANKS), and App.User for a program's own folder.
#define PLATFORM_LOG_MEM_MAX 16384
@@ -7031,7 +7134,7 @@ audioFmFree(audio, v); // key off, back to everyone
+ midiBank the OP2 instrument bank for FM MIDI (music.lib): its name in SHARED\BANKS or the user's BANKS folder (an add-on's as VENDOR\NAME), or a path from the DVX folder; not set, the bundled one (Control Panel's Sounds page picks it)
A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, volume) writes all three (an empty driver stores auto, a midi index out of range stores auto). audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.
Desktop Sounds
The library plays the sounds DVX makes for its events (dvxPlaySound in dvxApp.h): when it loads it sets DVX's sound hook to audioDeskPlay(event, wait). The file of each event is read from DVX.INI [sounds] at the first sound: the key is audioDeskKey(event) and the value a WAV or VOC file, empty or missing for none: a name alone is found in the sound folders (the user folder's SOUNDS first, then SHARED\SOUNDS: platformKindFind), anything else is a path; audioDeskReload makes the next sound read the section again (the Control Panel calls it after saving). The sounds play on a context of the library's own, so no program's end cuts one short, at full volume under the master volume. When no sound device starts, the sounds stay silent without trying again until the next reload. With wait the call returns once the sound has played (at most 5 seconds); after the exit sound further events are quiet (DVX is ending). audioDeskLabel(event) names an event for people, audioDeskPlayFile(path) plays any file the same way (to try one out). audioDeskFile(prefs, event) reads an event's file from a copy of DVX.INI ("" for none) and audioDeskSetFile(prefs, event, path) sets it; the library's own reading of [sounds] goes through audioDeskFile too. The default set, made by DVX's own sound generator at build time, is in SHARED\SOUNDS.
@@ -8821,6 +8924,44 @@ free(matches);
src/include/basic/hlpfile.bas declares the BASIC functions (hlpFileBas.c): HlpOpen, HlpClose, HlpTopicCount, HlpTopicId$, HlpTopicTitle$, HlpFindTopic, HlpStartTopic, HlpTocCount, HlpTocTitle$, HlpTocTopic, HlpTocDepth, HlpTocExpanded, HlpIndexCount, HlpIndexKeyword$, HlpIndexTopic, HlpSearch, HlpSearchTopic, HlpLoadTopic, HlpRecordType, HlpRecordFlags, HlpRecordText$, HlpRecordLink$, HlpRecordImageSave, and HlpWrap$, with the HLP_REC_, HLP_NOTE_, and HLP_IMG_ constants of hlpFormat.h.
A handle (a small number, 0 = none) is an open file plus what the program last asked of it -- HlpSearch keeps the topics found for HlpSearchTopic, HlpLoadTopic copies a topic's records for the HlpRecord functions -- since BASIC cannot take a callback or a byte array. Each handle records the program that opened it; the shell's app-cleanup hook closes what a program leaves open when it ends. hlpFileBasHandle(handle) gives the HlpFileT behind a handle to C code (the HelpPane control's ShowTopic uses it). A returned string stays valid until the next call returning one (the BASIC runtime copies it at once).
+
+
HTTP Library
+
An HTTP/1.1 client: GET, POST, and other requests to a web server over the network library's sockets, run a step at a time like the transfer and package jobs, so a program draws its progress and stays responsive. Software talks to the package server with it.
+
Header: http/http.h
+
Loaded as: bin/libs/kpunch/http/http.lib (it needs the network library, dvxnet)
HttpT *h = httpCreate("GET", "http://example.com/v1/catalogue");
+
+if (h && httpStart(h, nowMs)) {
+ while (httpPoll(h, nowMs) == HttpBusyE) {
+ // draw progress (httpProgress), yield to other programs
+ }
+}
+
+// HttpDoneE: httpStatus(h) is 200, 404, ...; httpBody(h, &len) the reply
+// HttpFailedE: httpMessage(h) says why
+httpFree(h);
+
nowMs is a millisecond clock (PLATFORM_TICKS_TO_MS(platformClock())); it may wrap. Each httpPoll does one bounded piece of work: start connecting, send, or read up to 4 KB; on DOS it also lets the network stack run. Every request says Connection: close.
+
Function Description
+ -------- -----------
+ httpCreate(method, url) A request; NULL when out of memory.
+ httpHeaderAdd(h, name, value) A header to send (before starting).
+ httpBodySet(h, data, len, contentType) A body to send (copied); Content-Length is sent for it, and for any POST or PUT.
+ httpSaveTo(h, path, resume) The body to a file instead of memory; with resume, a part already there is kept and the rest asked for (a Range request).
+ httpTimeoutSet(h, ms) Give up after ms without progress (HTTP_TIMEOUT_DEFAULT_MS, 30 seconds, to start with).
+ httpStart(h, nowMs) False (httpMessage) for an address that is not http://.
+ httpPoll(h, nowMs) HttpBusyE, HttpDoneE, or HttpFailedE.
+ httpProgress(h, &done, &total) Bytes of the body so far (counting a resumed file's first part), and the whole, HTTP_SIZE_UNKNOWN until the server says.
+ httpStatus(h), httpHeader(h, name) The reply's status and its headers (any case).
+ httpBody(h, &len) The body in memory, NUL-terminated ("" when it went to a file).
+ httpUrl(h) Where the reply came from, after redirects.
+ httpUrlParse(url, &parts, err, size) Split an address into host, port, and path.
+
What Comes Back
+
Any status is an answer (HttpDoneE): the program decides what 404 or 500 means. HttpFailedE is for no answer: a bad address, a name that cannot be found, no connection, silence past the timeout, a reply that is not HTTP, or one cut short. Bodies may be sized (Content-Length), chunked, or end when the server closes; they go to memory up to HTTP_MEMORY_MAX (1 MB), past which the request fails -- save big ones to a file. Redirects (301, 302, 303, 307, 308) are followed, HTTP_REDIRECTS_MAX (5) at most; after a 303, or a 301 or 302 of a POST, the request becomes a GET without its body.
+
Saving to a file: only a 2xx body is written to it. A resumed download that the server answers with 206 is appended to the part there; one it answers with 200 (it will not resume) starts the file again; 416 (asked for past the end: the file was already whole) leaves it as it is. Any other status's body -- an error page -- goes to memory and the file stays as it was, so a download can be resumed later. Check the file afterwards (its size and SHA-256, as the package library does) before using it.
+
Only plain http:// works: there is no encryption, so send nothing secret, and check what comes back by its signature or hash.
+
Joystick Library
Game controllers for DVX programs behind one API, whatever the hardware: the devices the drivers found, axes calibrated to -32768..32767 with a dead zone round the centre, buttons, hats, read when the program wants (polled) or delivered as events. A program's handles close when it ends, however it ends. Calibration and the other settings live in DVX.INI [joystick]; the Control Panel's Game Controllers page sets them.
@@ -8881,6 +9022,37 @@ free(matches);
For a settings program (the Control Panel's Game Controllers page) it also declares: JoyHats, JoyAxisPresent(joy, axis), JoyCanCalibrate(joy) (JoyInfoT hatCount, axisPresent, JOY_CAP_CALIBRATE); JoyConnected(joy) (joyRead's connected); JoyRescan (joyRescanApp for the calling program: its own handles and STICK readings go first; -1 while another program has a joystick open); JoyRawAxis(joy, axis) and JoyRawButtons(joy) (joyGetRaw); JoySetCalibration(joy, axis, minV, centerV, maxV) and JoyClearCalibration(joy, axis) (joySetCalibration); JoyDeadZone / JoySetDeadZone (joyGetDeadZone / joySetDeadZone); JoyChoiceCount(which), JoyChoiceLabel$(which, index), JoyChoice(which), JoySetChoice(which, index) with JOY_CHOICE_GAMEPORT = 0 and JOY_CHOICE_FALLBACK = 1 (JoyChoiceE; joyChoices / joyChoiceGet / joyChoiceSet); JoyKeyCount and JoyKeyName$(index); JoyKbdSlotCount, JoyKbdSlot(slot), JoySetKbdSlot(slot, keyIndex) (joyKbdSlotGet / Set); JoySetting$(key, defaultValue) and JoySetSetting(key, value) ("" removes; joySettingGet / Set); JoySettingsLoad(prefs) and JoySettingsStore(prefs) with a prefs.bas handle (prefsBasHandle); JoyCalibration(joy, axis, part) (joyGetCalibration; part JOY_CAL_MIN / JOY_CAL_CENTER / JOY_CAL_MAX, joystick.h; 0 when joyGetCalibration fails); JoyIsKeyboard(joy) (JOY_CAP_KEYBOARD); JoyKeyIndex(keyName), JoyKeyCode(index), JoyKeyNameOf$(rawCode) (joyKeyIndex / joyKeyCode / joyKeyNameOf). CONSTs JOY_DEADZONE_DEFAULT / _MAX, JOY_POLL_HZ_DEFAULT / _MIN / _MAX, and JOY_KEY_POLL_HZ mirror joystick.h. Lists (choices, keys, slots) are numbered from 0; True is -1.
The BASIC runtime calls joyBasStick(n) and joyBasStrig(n) for QBasic's STICK and STRIG. Joystick A is device 0 (axes 0-1, buttons 0 and 1); joystick B is device 1, or axes 2-3 and buttons 2-3 of device 0 when it is the only device and has more than two axes. STICK(0) reads A's X and remembers A's Y and B's X and Y for STICK(1) to STICK(3) (per program, forgotten when it ends); positions are 1 to 200 ((value - JOY_AXIS_MIN) x 199 / 65535 + 1), 0 without that joystick. Even STRIG numbers use joyButtonWentDown (0 A button 1, 2 B button 1, 4 A button 2, 6 B button 2), odd ones the state joyRead reports; True is -1.
+
+
JSON Library
+
Reads JSON text -- a web service's reply, a settings file -- into a tree, finds values in it by path, and quotes text for JSON of your own. The package server's replies are read with it.
jsonParse(text, len, err, errSize) reads a document and returns it (NULL when it is not JSON: err says why and on which line; jsonFree frees it). Its nodes are numbered from 0, the root. jsonFind(doc, node, path) finds a node from another: a path is member names and [index]es, such as packages[3].version, [0], or "" (the node itself); members match by exact name, the first when a name repeats. JSON_NO_NODE stands for a node that is not there, and every function accepts it.
+
Function Description
+ -------- -----------
+ jsonType(doc, node) JsonNullE, JsonBoolE, JsonNumberE, JsonStringE, JsonArrayE, JsonObjectE; JsonNoneE for no node.
+ jsonText(doc, node) A string's text ("" for anything else).
+ jsonNumber(doc, node) A number's value, a double (0 for anything else).
+ jsonBool(doc, node) True only for true.
+ jsonCount(doc, node) An array's items or an object's members (0 for anything else).
+ jsonChild(doc, node, index) Item or member index, in order.
+ jsonKey(doc, node) A member's name ("" for anything else).
+
Strings are held decoded: the escapes \n, \t, \", \\, \/, \b, \f, and \r become their characters, \u escapes below 128 their character and others '?'; other bytes, UTF-8 included, pass through. Arrays and objects nest JSON_DEPTH_MAX (64) deep at most.
jsonQuote(text, out, outSize) writes text as a JSON string -- quotes, escapes, control characters as \u00XX -- and returns the length it needs (as snprintf does), so a NULL out with size 0 measures it.
+
Writing Games for DVX
A game in DVX is a program with its own main loop (hasMainLoop = true in its SHELL_APP_DESCRIPTOR). DVX gives it its own task: appMain runs the game loop itself, draws into the game's window, and waits for each frame on the high-resolution clock while the other programs keep running. Nothing in DVX has to stop for a game: the desktop, other programs, the Task Manager (Ctrl+Esc), and help all stay available.
@@ -9164,7 +9336,7 @@ End Sub
Command Does
------- ----
make Builds the module with its resources, and NAME.hlp from the help source
- make package Lays out pkg/ exactly as the files go into the DVX directory
+ make package Lays out pkg/ exactly as the files go into the DVX directory, and makes NAME.dpk, the add-on package Software installs
make install DVX_DIR=/path/to/dvx Packages, then copies pkg/ into a DVX installation
make clean Removes everything the build made
Every .c file in the project directory is compiled with warnings as errors (-Wall -Wextra -Werror) and every SDK include directory on the path. Add your own flags with CFLAGS, for example CFLAGS=-I../myLib to use a library's header. VENDOR (default local) is the vendor directory the module installs under.
@@ -9415,7 +9587,7 @@ End Sub
Packaging and Installing
-
make package lays out pkg/ exactly as the files go into the DVX directory; make install DVX_DIR=... copies it there. VENDOR (default local) names the vendor directory; use a short name of your own (8 characters at most).
+
make package lays out pkg/ exactly as the files go into the DVX directory and makes NAME.dpk from it, the add-on package anyone installs with DVX's Software program; make install DVX_DIR=... copies pkg/ into a DVX directory. VENDOR (default local) names the vendor directory; use a short name of your own (8 characters at most: a-z, 0-9, and _, since the package name is VENDOR.NAME in lower case).
Kind Directory Files
---- --------- -----
Program APPS/<VENDOR>/<NAME>/ NAME.app, NAME.hlp, DATA/ with the DATA files (if any)
@@ -9425,9 +9597,28 @@ End Sub
Programs carry their compiled help. Widgets and libraries carry help sources, which DVX compiles into its system help when the set of installed modules changes.
.dep files are written with DOS line endings.
A program's data files (levels, graphics, sounds it loads at run time) go in the Makefile's DATA variable, for example DATA = levels sounds; the package puts them in the DATA folder beside NAME.app, where the program finds them through shellDataPath (BASIC: App.Data). Small ones can instead travel inside the .app as binary resources (see the .res file).
-
To ship a module, zip the contents of pkg/; the user unpacks it into the DVX directory. A library's header and .bas file go to the developers who use it, not into the DVX directory.
-
Removing a module is deleting its directory.
+
To ship a module, give out NAME.dpk: Software installs it (and what it needs from the same folder), records it, and removes it cleanly again. A library's header and .bas file go to the developers who use it, not into the package.
+
The Add-on Package
+
The package's details come from sdk.mk settings, on the make command line or in the project Makefile:
+
Setting Meaning
+ ------- -------
+ PKG_TITLE The name Software shows (default NAME)
+ PKG_VERSION major.minor.patch (default 1.0.0); raise it for every version you hand out, so Software installs it over the old one
+ PKG_SUMMARY A sentence about it
+ PKG_PUBLISHER Who publishes it; also PKG_AUTHOR, PKG_LICENCE, and PKG_HOMEPAGE
+ PKG_NEEDS The packages it needs, id=oldest-version each: PKG_NEEDS="acme.cards=1.0 acme.dice=2.1"
+ PKG_KEY The secret key file to sign it with
+
Programs, libraries, and widgets are marked with the binary interface version of the SDK that built them (see Binary Interface Version); Software will not install them on a DVX of another version. Sign packages so the people who install them can tell they are yours:
+
bin/dvxpkg keygen "Acme Games" acme # once: acme.SEC and acme.KEY
+make package PKG_KEY=acme.SEC PKG_VERSION=1.1.0
+bin/dvxpkg info NAME.dpk # what the package says
+
Give acme.KEY to the people who install your packages (Software, File > Trust a Publisher's Key) and keep acme.SEC private and backed up: anyone who has it can sign as you, and an update must be signed with the key that signed the version installed. bin/dvxpkg also signs and checks packages and lists their contents; on DVX, SYSTEM/DVXPKG.EXE does the same, and installs and removes packages. The package library and its file format are described under Package Library.
+
Publishing on the Package Server
+
Software's Available and Updates pages list the packages on the DVX package server. To publish one there, make an account on the server (POST /v1/account/register, then the link in the mail), give its email and password in Software's File > Package Server, and choose File > Publish a Package; Software's Published page shows each version's review. From the build machine, sign in (POST /v1/account/login gives a session token) and upload the package with it:
The server checks the package as DVX does before installing it and holds it for review; GET /v1/mine shows its state, and the note when it is turned down. Once the server's operator approves it, it is signed with DVX's repository key and listed, and every DVX that has an older version is offered the update. An id belongs to the account that first uploaded it, so later versions must come from the same account; ids of the vendor kpunch are DVX's own. The server's API and its rules are in server/README.md in the DVX source tree.
libtasks -- Cooperative Task Switching
@@ -9889,7 +10080,7 @@ if (rc != MUSIC_OK) {
software the software synthesizer
On the FM chip and the MPU-401 port the song is played by the timer service (500 times a second), so it keeps time however busy the programs are. On the FM chip every note takes a voice from audio.lib's shared pool; when none is free the note plays on the software synthesizer instead, which then runs beside the chip (its stream is always open for that). The software synthesizer runs in the stream's fill function (the shell's idle time), 64 frames between event checks, 150 ms ahead of what is heard; a program that keeps the desktop busy every frame should call audioPump once a frame, as for any stream.
General MIDI on FM
-
Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank names another OP2 file instead (a path from the DVX folder), for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. The bank is read once, at the first MIDI music that needs it.
+
Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). The bank is read once, at the first MIDI music that needs it, so a new choice takes effect when DVX next starts.
What is played: note on / off, velocity, program change, channel volume (CC7), expression (CC11), pan (CC10: left, both, or right on an OPL3 and the software synthesizer), sustain pedal (CC64), pitch bend with the bend range from RPN 0 (default 2 semitones), all notes off (CC123, CC120), reset controllers (CC121), and General MIDI System On (a reset). Channel 10 plays percussion. Loudness is velocity x volume x expression x the music's volume on a 40 log10 curve, added as attenuation to the voice's level. OP2 banks are tuned for DMX, which sounds note n where MIDI has note n + 12, so notes are played an octave up from the bank's offsets. Not played: modulation (CC1), aftertouch, the FM chip's own rhythm mode, four-operator voices.
The Software Synthesizer
When there is no FM chip (or it is busy), MIDI plays on DVX's own software FM synthesizer: an integer two-operator FM chip modelled on the OPL2 (the same log-sine and exponential tables, waveforms 0 to 3, feedback, the chip's envelope rates and key scaling, key scale level, tremolo, and vibrato) with 24 voices, rendering at the device's rate into a stream. It is driven with the same bank and the same register values as the chip, so music sounds alike on both. The host tests compare it with emu8950, an accurate OPL2 emulator, for several patches: pitch, loudness over time, and harmonics agree closely. Cost on a 486: roughly 1 to 2 percent of a 486DX2-66 per sounding voice at 22,050 Hz.
The GENMIDI instrument bank is from Freedoom 0.13.0, Copyright (C) 2001-2024 Contributors to the Freedoom project. All rights reserved. BSD 3-clause licence:
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the Freedoom project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
Package Server Library
+
The client of a DVX package server: what it offers (its catalogue), what it has that is newer than the packages installed (DVX itself among them), and fetching packages, with everything they need that is not installed, into a download folder; and, with an account on the server, publishing packages and listing what the account has sent. There the package library reads them (pkgOffersInFolder) and plans and installs them like any package file, so a package from the server is checked and trusted exactly as one from a disk. Software uses it for its Available and Updates pages.
+
Header: pkgnet/pkgNet.h
+
Loaded as: bin/libs/kpunch/pkgnet/pkgnet.lib (it needs the package, HTTP, and JSON libraries)
+
BASIC: pkgnet.bas (PkgNetOpen, PkgNetCatalogue, PkgNetUpdates, PkgNetFetch, PkgNetRunFor, PkgNetRows$, ...), with pkg.bas's PkgOfferFolder to plan what was fetched.
One job at a time; each poll does one piece of it (a piece of a request, or a slice of checking a file). nowMs is a millisecond clock, as for the HTTP library.
+
Function Description
+ -------- -----------
+ pkgNetCreate(server, root, dir) A client of the server at server ("http://host[:port]"), for the DVX folder root ("" the current one), fetching into dir.
+ pkgNetCatalogueStart(n, nowMs) Read the catalogue: every package the server offers, at its newest version.
+ pkgNetUpdatesStart(n, nowMs) Ask what is newer than the packages installed (this DVX's version and ABI are sent with the list).
+ pkgNetFetchStart(n, ids, count, nowMs) Fetch packages by id, with what they need that is not installed in a version that will do.
+ pkgNetPoll(n, nowMs) PkgNetBusyE, PkgNetDoneE, or PkgNetFailedE.
+ pkgNetCancel(n) Stop the job; a download stopped part way is kept to resume.
+ pkgNetEntries(n, &count) The answer: the catalogue (after reading it or fetching) or the updates, as PkgNetEntryT (the manifest's fields and needs, the package file's size and SHA-256, and whether the repository signed it).
+ pkgNetDb(n) The installed packages, as the last job read them.
+ pkgNetProgress(n, &done, &total) A fetch's bytes so far and in all.
+ pkgNetCurrent(n), pkgNetMessage(n) The package the job is on, and why it failed.
+ pkgNetDir(n), pkgNetClear(n) The download folder, and removing what the last fetch put there.
+ pkgNetConfigRead(&c), pkgNetConfigWrite(&c) DVX.INI's [packages]: server (a host name), port, user (an account's email), and password (scrambled by prefsSetSecret), as a PkgNetConfigT (the defaults: PKGNET_SERVER_DEFAULT, port 80, no account).
+ pkgNetConfigUrl(&c, out, size) The address they make: "http://host", with ":port" when not 80.
+ pkgNetAccountSet(n, user, password) The account the handle's account jobs use.
+ pkgNetUploadStart(n, path, nowMs) Publish the package file at path for the server's review.
+ pkgNetMineStart(n, nowMs), pkgNetMine(n, &count) What the account has sent, each version with its state (pending, approved, rejected, withdrawn) and the review's note.
+
What a Fetch Checks
+
Each package fetched must have exactly the size and SHA-256 the catalogue gives; one that does not is removed and the fetch fails. The repository's signature of it is fetched beside it (NAME.SIG), so whether DVX trusts it is decided as for any package: a package signed by a key in SYSTEM\KEYS (DVX's own repository key) is trusted by DVX. The server itself is never trusted: it is plain HTTP, and only the signatures count.
+
Each package's file is named from its id and version (eight hex digits, a DOS name), so a download that stops part way (NAME.PRT) goes on from where it stopped the next time the same version is fetched, and one fetched before is checked again rather than downloaded again.
+
What a package needs that no version on the server will do for is left for the planner to name. Installing what was fetched is the package library's job: read the folder's offers, plan, and run the install as a job.
+
An Account
+
The account's jobs sign in first (POST /v1/account/login) when the handle has no session yet, keep the session for the handle's later jobs, and sign in once more when the server answers that the session has gone. A wrong email or password fails the job saying so. The password is DVX.INI's (prefsGetSecret); the session's token is kept only in memory and wiped, with the password, when the handle is freed.
+
+
+
Package Library
+
Add-on packages: the .DPK file format, manifests, Ed25519 keys and signatures, the packages installed in a DVX folder, the keys DVX trusts, planning what an install or removal takes, and installing and removing a package a step at a time. Software, Package Builder, the IDE's File > Make Package, DVXPKG.EXE, the build machine's dvxpkg, and Setup all use it.
+
Header: pkg/pkg.h (it includes the others below)
+
Loaded as: bin/libs/kpunch/pkg/pkg.lib
+
BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys).
+
A Package
+
A package is NAME.DPK: a ZIP read front to back, whose first entry is PACKAGE.INI, the manifest; then PACKAGE.SIG when the publisher signed it; then the files, named relative to the DVX folder with / between parts.
+
[package]
+format = 1
+id = acme.chess ; vendor.name, each 1..8 of a-z 0-9 _
+name = Chess
+version = 1.2.0
+kind = app ; app library widget font bank theme wallpaper sound help bundle system
+summary = Play chess against the computer.
+publisher = Acme Games ; also author, licence, homepage
+dvx = 1.0.0 ; the oldest DVX it runs on
+abi = 10 ; programs, libraries, widgets: DVX_ABI_VERSION
+[depends]
+acme.cards = 1.0.0 ; id = the oldest version that will do
+[files]
+APPS/ACME/CHESS/CHESS.APP = 61234 <SHA-256, 64 hex digits>
+
Every file is listed with its size and SHA-256 and every listed file is in the package, so the one signature over the manifest's bytes covers everything. Each kind keeps to its own folders (pkgKindFolder gives the folder for an id; pkgFileAllowed checks a name):
+
Kind Folder
+ ---- ------
+ app, help APPS/VENDOR/NAME (help may also go beside a library or widget of its name)
+ library, widget LIBS/VENDOR/NAME, WIDGETS/VENDOR/NAME
+ font, bank SHARED/FONTS/VENDOR, SHARED/BANKS/VENDOR
+ theme, wallpaper SHARED/THEMES/VENDOR, SHARED/WPAPER/VENDOR
+ sound SHARED/SOUNDS/VENDOR
+ bundle no files: a set of packages, its dependencies
+ system DVX itself (kpunch.dvx): anywhere but the user folder
+
Manifests (pkgManifest.h)
+
Function Description
+ -------- -----------
+ pkgManifestParse(text, len, &m, err, size) A manifest's text; then pkgManifestCheck before trusting it.
+ pkgManifestCheck(&m, err, size) The id, the kind's rules, every file's name and folder, nothing listed twice, no dependency on itself.
+ pkgManifestFormat(&m, &len) The text (CR LF lines), malloc'd.
+ pkgTemplateRead(path, &m, err, size) A template: a manifest whose format and [files] a build writes, so it may leave them out; other sections are ignored.
+ pkgKindFolder(kind, id, out, size) The folder a package id of kind keeps its files in (APPS/ACME/CHESS); false for bundle and system.
+ pkgKindIsCode(kind) Programs, libraries, and widgets: their manifests need an abi.
+ pkgIdValid(id), pkgIdSplit(id, vendor, name) Package ids.
+ pkgVersionParse, pkgVersionFormat, pkgVersionCompare major.minor.patch versions.
+
Keys and Signatures (pkgSign.h)
+
A key pair is NAME.KEY (public: id, name, public key) and NAME.SEC (secret: id, name, seed), small INI files. A key's id is the first 8 bytes of its public key's SHA-256, so a signature names its key. The repository key that signs DVX's own packages is kept offline; the server never holds a secret key.
+
Function Description
+ -------- -----------
+ pkgRandomBytes(buf, len) Random bytes for a seed: the security library's generator topped up with fresh hardware entropy.
+ pkgKeyWrite(seed, name, base, &key, err, size) Make a pair from seed and write BASE.KEY and BASE.SEC.
+ pkgKeyRead, pkgSecretRead, pkgSigRead Read a key, secret key, or signature file.
+ pkgSign(&secret, msg, len, &sig) Sign bytes (a manifest's).
+ pkgSigCheck(&sig, &key, msg, len) True when key made sig over the bytes.
+ pkgSecretWipe(&secret) Clear a secret key from memory once it has signed.
+
Building and Reading Packages (pkgDpk.h)
+
Function Description
+ -------- -----------
+ pkgDpkBuild(&templ, folder, out, secret, err, size) Build from a template and every file under folder, named relative to it (a folder laid out like the DVX folder); secret NULL: unsigned. A program, library, or widget without abi gets DVX_ABI_VERSION.
+ pkgDpkBuildFiles(&templ, files, count, out, secret, err, size) The same from a list of PkgDpkFileT (the name in the package, the file it comes from).
+ pkgDpkFolderFiles(folder, prefix, &files) Every file under folder as that list, named prefix/...; pkgDpkFilesFree frees it.
+ pkgDpkReadInfo(path, &info, err, size) The manifest and PACKAGE.SIG, checked; pkgDpkInfoFree.
+ pkgDpkCheckFiles(path, &info, err, size) Every file's size and SHA-256 against the manifest.
+ pkgDpkSign(path, &secret, detached, err, size) Sign into the package, or beside it as NAME.SIG.
+ pkgDpkSigner(&info, detached, keys, count) Which of keys made the signature (or the detached one).
Installed Packages and Trust (pkgDb.h, pkgTrust.h)
+
Each installed package has a record, SYSTEM/PACKAGES/VENDOR/NAME.INI: its manifest as installed, whether it was chosen or only needed by another, and the key that signed it. pkgDbLoad reads them all; pkgDbFind, pkgDbOwner (who has a file), pkgDbPut, and pkgDbDrop keep them. Setup writes DVX's own record (kpunch.dvx, with no files).
+
pkgKeyringLoad reads the keys DVX trusts: SYSTEM/KEYS (DVX's own) and the user's KEYS folder (publishers the user chose); pkgKeyringAdd trusts another. pkgTrustOf says how far a package is trusted (PkgTrustSystemE, PkgTrustUserE, PkgTrustNoneE). The rules the planner keeps: kpunch.* packages must be signed by a SYSTEM key; an upgrade must be signed by the key that signed the installed version (or a SYSTEM key); an unsigned package from a file is installed only after a warning.
+
Planning (pkgPlan.h)
+
An offer (PkgOfferT) is a package that can be installed: pkgOfferRead reads one file, pkgOffersFor reads a file and every package in its folder (what it may need), pkgOffersInFolder a whole folder. pkgPlanInstall(db, offers, count, wanted, wantedCount, &plan) plans installing the offers wanted with everything they need, in the order they go in, each checked (the DVX and ABI it needs, files another package has, a newer version installed, the signing rules); plan.err says why when it fails, plan.bytes is what it unpacks, and plan.stale lists the add-ons an update of DVX itself would leave unable to load. pkgPlanDependents lists the installed packages that need some going (they go too); pkgPlanOrphans those only they needed.
+
Installing and Removing (pkgOp.h)
+
pkgOpInstall(db, path, ring, explicitly) and pkgOpRemove(db, id, removeChanged) make an operation; pkgOpStep runs it a bounded piece at a time (PKG_OP_STEP bytes) until PkgOpDoneE or PkgOpFailedE (pkgOpMessage says why), with pkgOpProgress for a progress bar. Installing unpacks every file under a scratch name beside where it goes, checking each against the manifest, and only then swaps them all in, keeping the files they replace until every swap is done (and putting them back if one fails); then it removes what an older version had that this one does not, and writes the record. Removing deletes the files unchanged since they were installed (all of them with removeChanged), the folders that leaves empty, and the record; pkgOpKept lists the changed files it left. pkgOpFree before the swap cancels an install; after it, the operation is finished first.
+
The Package Tool
+
DVXPKG.EXE (DVX's SYSTEM folder) and bin/dvxpkg (the SDK, on the build machine) take the same commands:
On DOS, DVXPKG remove is the way out when an add-on keeps DVX from starting (DVX /SAFE, or holding Shift, also starts DVX without add-ons). The SDK's make package builds NAME.dpk from a project (sdk.mk's PKG_* settings), and the IDE's File > Make Package from a BASIC project.
+
Printing Library
Printer ports and print jobs, text laid out on pages, pictures for dot-matrix and laser printers, the printer settings, and the common Print and Page Setup dialogs. Notepad, Image Viewer, Icon Editor, the Control Panel's Printer page, and BASIC's LPRINT all print through it; any program can.
diff --git a/screenshots/86box/addons-addon-no.png b/screenshots/86box/addons-addon-no.png
new file mode 100644
index 00000000..07e38fee
--- /dev/null
+++ b/screenshots/86box/addons-addon-no.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:87a79d1bb9a57eba5bc045892b359d85c20a332a95de259c233ec36b1de3ef2e
+size 18229
diff --git a/screenshots/86box/apps-clock.png b/screenshots/86box/apps-clock.png
index b3aa3b16..aac25d3e 100644
--- a/screenshots/86box/apps-clock.png
+++ b/screenshots/86box/apps-clock.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8a6e4ccd7233092f377ac0398a323d2a57f465da9a91c633e8a09402794f5ebd
-size 17977
+oid sha256:c47ef1d6f91f6bf682c65cffe7a635fac96e7b09d59fd05a54736b9ae8e72880
+size 17993
diff --git a/screenshots/86box/apps-cpanel.png b/screenshots/86box/apps-cpanel.png
index 4df6fcb0..eb7203d1 100644
--- a/screenshots/86box/apps-cpanel.png
+++ b/screenshots/86box/apps-cpanel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cf2fc814413dc000180596ba36f49716ec9571669a1959a2a5ce9bbfbad33f63
-size 16430
+oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
+size 16427
diff --git a/screenshots/86box/apps-desktop.png b/screenshots/86box/apps-desktop.png
index 4f9610d8..9fc09f1a 100644
--- a/screenshots/86box/apps-desktop.png
+++ b/screenshots/86box/apps-desktop.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5d14ced0f9793e53e27952b0ea5f69475f373a8c389838877ccb71ae8170818f
-size 18508
+oid sha256:0a697ed80887720ffc0e258516c4a59068e86521f79c4d9d777e5b6d703a73cf
+size 18588
diff --git a/screenshots/86box/apps-dvxbasic.png b/screenshots/86box/apps-dvxbasic.png
index adafd78e..16b7bf11 100644
--- a/screenshots/86box/apps-dvxbasic.png
+++ b/screenshots/86box/apps-dvxbasic.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ca33ce84d1585ab033f84a3e9063897633471477cd038439db44659931e86999
-size 18318
+oid sha256:c5b9aedac500ad8ff4cbbb7b59bf49751f4bde23f6e8f8a3ae9f0f34424307a1
+size 18393
diff --git a/screenshots/86box/apps-iconed.png b/screenshots/86box/apps-iconed.png
index f305a142..658cb98c 100644
--- a/screenshots/86box/apps-iconed.png
+++ b/screenshots/86box/apps-iconed.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a8ea0efb748f7f264cd034cdafbc33b5d35aa9ac0324be02d0daacf213aad97d
-size 14855
+oid sha256:35e2e173eb0155a1799f985effc2d4100ea31be802ad7e312d5ca66751601fa0
+size 14832
diff --git a/screenshots/86box/apps-imgview.png b/screenshots/86box/apps-imgview.png
index a50ad693..65619ad3 100644
--- a/screenshots/86box/apps-imgview.png
+++ b/screenshots/86box/apps-imgview.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c92d11a6a8ad8e9194751f84f37f126d728ee4789e075a2b55f17053c0d0fee1
-size 12908
+oid sha256:20216cfc2f5c629596ee18af24de18501b2431af8ee731ceb5be4427e219661b
+size 12892
diff --git a/screenshots/86box/apps-notepad.png b/screenshots/86box/apps-notepad.png
index 5185b7e5..f8d5861d 100644
--- a/screenshots/86box/apps-notepad.png
+++ b/screenshots/86box/apps-notepad.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:690342da9774fdaaef6f8d681afbe50eb8e99158362c91d38e5c43dd89dc19d1
-size 13734
+oid sha256:cbbc99f6095370b23c0cda96486df13fb5c1e6654ff371b9a67b8fe62a116eda
+size 13750
diff --git a/screenshots/86box/apps-resedit.png b/screenshots/86box/apps-resedit.png
index 6778216b..4becf7fd 100644
--- a/screenshots/86box/apps-resedit.png
+++ b/screenshots/86box/apps-resedit.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e27933594c4af7e9623583d9113f7ace247b8ae92eee8bfd602d1920eb266b26
-size 14149
+oid sha256:3fc86dd4c3acc583cdb962e02b1c3a563a67bd5d84c0ed1a19314937ab191c51
+size 14168
diff --git a/screenshots/86box/banked-bk-back.png b/screenshots/86box/banked-bk-back.png
index 60f8bb96..7976b145 100644
--- a/screenshots/86box/banked-bk-back.png
+++ b/screenshots/86box/banked-bk-back.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:991866c7119668909f5e578089f241f97d42b0e97c81c48cbc6f489bcddc0781
-size 18485
+oid sha256:3425e4a35d856bef29f68f9732c7fad18669dfd3ebd1178835c0191827029cfb
+size 18540
diff --git a/screenshots/86box/banked-bk-desk.png b/screenshots/86box/banked-bk-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/86box/banked-bk-desk.png
+++ b/screenshots/86box/banked-bk-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/86box/banked-bk-drag.png b/screenshots/86box/banked-bk-drag.png
index c7870511..51540044 100644
--- a/screenshots/86box/banked-bk-drag.png
+++ b/screenshots/86box/banked-bk-drag.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b9512db9b61617ef2f25ccf302b3b600575ef5fd5b94d793c9dec199a597666d
-size 15161
+oid sha256:d815eedadc4035fea2027775eb340ae6b33b2b4f9bf90115ddacfe565096f7d9
+size 15053
diff --git a/screenshots/86box/banked-bk-note.png b/screenshots/86box/banked-bk-note.png
index 06a90b66..f80814d6 100644
--- a/screenshots/86box/banked-bk-note.png
+++ b/screenshots/86box/banked-bk-note.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6ed706f905ee45aadee7723995d1bb203a9c49de2d6e675bd96de0e470f0309a
-size 13741
+oid sha256:1ffe8d8b71867ed7b7c676213a0b715f4de49565e1e83f0694466d2d701673cb
+size 13754
diff --git a/screenshots/86box/boot-boot.png b/screenshots/86box/boot-boot.png
index aaa46454..c93e492a 100644
--- a/screenshots/86box/boot-boot.png
+++ b/screenshots/86box/boot-boot.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/86box/cpanel-cp-color.png b/screenshots/86box/cpanel-cp-color.png
index 00d41f40..5261386c 100644
--- a/screenshots/86box/cpanel-cp-color.png
+++ b/screenshots/86box/cpanel-cp-color.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6a695a45d25535a2cdfa489e932d221c87bc029e8e79991478ddcd1925d37b6e
-size 18280
+oid sha256:660bf211134751affcd3a05e3d5e8e8c297cda750a417873a642c2ee72861542
+size 18266
diff --git a/screenshots/86box/cpanel-cp-desk.png b/screenshots/86box/cpanel-cp-desk.png
index 76360a2e..c912aaa5 100644
--- a/screenshots/86box/cpanel-cp-desk.png
+++ b/screenshots/86box/cpanel-cp-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cfe0af167d1535d62e34ddfd9e53deaaf6441e397d815d9e4246293b7e953be4
-size 14435
+oid sha256:97022e5d29c1c26e7d47625d3fb54121173c54b9f58f884a17c451a555a3b3b3
+size 14436
diff --git a/screenshots/86box/cpanel-cp-joy.png b/screenshots/86box/cpanel-cp-joy.png
index 902d99a9..a2a740d4 100644
--- a/screenshots/86box/cpanel-cp-joy.png
+++ b/screenshots/86box/cpanel-cp-joy.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6c5abef727eff67db56905b030e31f83dea87ad20391a4af15427cffd4996256
-size 16348
+oid sha256:eb4bdad8d10caa593d3cfab42126f537714a1d2ea70e06ca64656e26fbecb785
+size 16334
diff --git a/screenshots/86box/cpanel-cp-mouse.png b/screenshots/86box/cpanel-cp-mouse.png
index 4df6fcb0..eb7203d1 100644
--- a/screenshots/86box/cpanel-cp-mouse.png
+++ b/screenshots/86box/cpanel-cp-mouse.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cf2fc814413dc000180596ba36f49716ec9571669a1959a2a5ce9bbfbad33f63
-size 16430
+oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
+size 16427
diff --git a/screenshots/86box/cpanel-cp-net.png b/screenshots/86box/cpanel-cp-net.png
index e93f245d..8a0078a8 100644
--- a/screenshots/86box/cpanel-cp-net.png
+++ b/screenshots/86box/cpanel-cp-net.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c1f37bac1331a4f7d3c58ee9bd45aed805fdd8ffa95a059487e84f5ec5fb1e63
-size 16214
+oid sha256:27f0996dda3d29cd134257c40b19a6ef215ce45162999250c704004570995426
+size 16199
diff --git a/screenshots/86box/cpanel-cp-print.png b/screenshots/86box/cpanel-cp-print.png
index bd712763..6b3a71c0 100644
--- a/screenshots/86box/cpanel-cp-print.png
+++ b/screenshots/86box/cpanel-cp-print.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0978c06a46d4d39b7d09acc02714e6f48689346f67d05f94363b0819616fa9ff
-size 15645
+oid sha256:e35a2a6c663538c45692d66e5335d808e323bda1c5f1c53e51f1124a2e6cba85
+size 15909
diff --git a/screenshots/86box/cpanel-cp-sound.png b/screenshots/86box/cpanel-cp-sound.png
new file mode 100644
index 00000000..8b22b99b
--- /dev/null
+++ b/screenshots/86box/cpanel-cp-sound.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8af0d409e9b907747748030acb9e6974893bdd3a426d142d679a023bfd1ac6ce
+size 17643
diff --git a/screenshots/86box/cpanel-cp-types.png b/screenshots/86box/cpanel-cp-types.png
index cc510c12..1e405480 100644
--- a/screenshots/86box/cpanel-cp-types.png
+++ b/screenshots/86box/cpanel-cp-types.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:362e4f37e6e99b0368da74c2bc15b5e08d9d6bb6267aeddbccdd908745767993
-size 23159
+oid sha256:53601bf8609488ed1a85b388e405854ec038f216b2cf6d4350d749be05195dd5
+size 23172
diff --git a/screenshots/86box/cpanel-cp-video.png b/screenshots/86box/cpanel-cp-video.png
index 94174e34..ba8d7ec3 100644
--- a/screenshots/86box/cpanel-cp-video.png
+++ b/screenshots/86box/cpanel-cp-video.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9c7ca998583545535beb72a7cd1347ef66917f213df5a72783079a805cf0a142
-size 19483
+oid sha256:0811ca3be22aeba9decba8821ed65220c26b37d3155d01320fd8b46ec9116285
+size 19815
diff --git a/screenshots/86box/exit-cancel.png b/screenshots/86box/exit-cancel.png
index 05fb49db..cee10809 100644
--- a/screenshots/86box/exit-cancel.png
+++ b/screenshots/86box/exit-cancel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b1857f90c8a3b3df3504042146bda7ddafec400ff43afece3f4b55e7c1223e24
-size 14330
+oid sha256:3478e1d84decc5ac10844883062e6bea6885e30ec7ebe0101ebee7d9444ae619
+size 14351
diff --git a/screenshots/86box/exit-savechg.png b/screenshots/86box/exit-savechg.png
index 089b9519..74b3e466 100644
--- a/screenshots/86box/exit-savechg.png
+++ b/screenshots/86box/exit-savechg.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ceb4762d64848d9756af89143d495d13acbfd682f81f85d8941eb9b8450dbcb5
-size 14324
+oid sha256:c375e0886534255d93d2ae1ecf32683d0036cfbfb628320140a7a5fc9b019695
+size 14348
diff --git a/screenshots/86box/f1-h-cpanel.png b/screenshots/86box/f1-h-cpanel.png
index a2a88cdf..a75dbb95 100644
--- a/screenshots/86box/f1-h-cpanel.png
+++ b/screenshots/86box/f1-h-cpanel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c608a08ba5206a0cb5bfbb9c4845087b99fae8d9dff28323fc161fc2a48a5aab
-size 22602
+oid sha256:e6fe705e41b7a4dc8b969945e455b2d79fd32d517f78b829ded6cfd44324db70
+size 22663
diff --git a/screenshots/86box/filedlg-fd-back.png b/screenshots/86box/filedlg-fd-back.png
index 007a9b08..f579cfe5 100644
--- a/screenshots/86box/filedlg-fd-back.png
+++ b/screenshots/86box/filedlg-fd-back.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:53dcaf3108803a4510e3a2c2b422b79ab147a9f4437edc9dd96c77339d10a589
-size 15236
+oid sha256:7382ad12f6411a8448df45ea184d8306423a51e0fbd8bb2d46beeaa34b43ac29
+size 15234
diff --git a/screenshots/86box/filedlg-fd-list.png b/screenshots/86box/filedlg-fd-list.png
index 51f29728..e0f6968e 100644
--- a/screenshots/86box/filedlg-fd-list.png
+++ b/screenshots/86box/filedlg-fd-list.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0ccaa69aacc953385111373661bd2b3bc7e75b12edec75164f6d1a1720b5bb8e
-size 15337
+oid sha256:6c48f8df6224e37237a612f8440c83040123955ea3b7ce6c2c33907df55df575
+size 15346
diff --git a/screenshots/86box/filedlg-fd-nordy.png b/screenshots/86box/filedlg-fd-nordy.png
index f4b50424..383cd199 100644
--- a/screenshots/86box/filedlg-fd-nordy.png
+++ b/screenshots/86box/filedlg-fd-nordy.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7414f2f6a1a2c09989945d3fbe2bf5e0470a1873592ca12e05911ae81514f438
+oid sha256:b6b6cd04bf85ff797c3be50ecff2fe0a3efc61e19ea397fc00c31ec7369f1111
size 15443
diff --git a/screenshots/86box/filedlg-fd-open.png b/screenshots/86box/filedlg-fd-open.png
index 156d4715..56bee79c 100644
--- a/screenshots/86box/filedlg-fd-open.png
+++ b/screenshots/86box/filedlg-fd-open.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7729d94bde811e105e739f3acd5394012840d3d007bbea98c101b7e0535ee280
-size 15194
+oid sha256:fa786de9621f9d9a39373e800572fbbce9807a7c432f0330572908f602a2060a
+size 15205
diff --git a/screenshots/86box/fullmode-fm-desk.png b/screenshots/86box/fullmode-fm-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/86box/fullmode-fm-desk.png
+++ b/screenshots/86box/fullmode-fm-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/86box/fullscr-fs-desk.png b/screenshots/86box/fullscr-fs-desk.png
index 3e95b4f3..c2e26be0 100644
--- a/screenshots/86box/fullscr-fs-desk.png
+++ b/screenshots/86box/fullscr-fs-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b28bb041c7adbdf8f345ab1d0df79d1f44519ac65a8df42eaf53567cb15fefd6
-size 17396
+oid sha256:adb402decf2e7dc5f420b0f5d479eaaa0cec605c2e4b58ed44ab6575165a0fd5
+size 17558
diff --git a/screenshots/86box/hang-ended.png b/screenshots/86box/hang-ended.png
index f5c8b114..87a65414 100644
--- a/screenshots/86box/hang-ended.png
+++ b/screenshots/86box/hang-ended.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c7a6244ed6897f52b30ebc88e44f09a4e246e02ec7ce5d041fa3dd15ba7a72b6
-size 15159
+oid sha256:967335f41a1b4b76a584e0eeffbfd607ce3063794b7c9a066ef097e3ce152d3b
+size 15165
diff --git a/screenshots/86box/hang-taskmgr.png b/screenshots/86box/hang-taskmgr.png
index 68b484ca..f365e9ad 100644
--- a/screenshots/86box/hang-taskmgr.png
+++ b/screenshots/86box/hang-taskmgr.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f3bf75064c347afb77a293b7f3105d02ce1dbe48bf6ab4e380d4ca5defc59da0
-size 16185
+oid sha256:2953c993db395b28cbdae6e951af44a949cd201cda152d9ef7e9f4af80a661ec
+size 16192
diff --git a/screenshots/86box/install-in-desk.png b/screenshots/86box/install-in-desk.png
index 7786d5eb..00444c34 100644
--- a/screenshots/86box/install-in-desk.png
+++ b/screenshots/86box/install-in-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0c83a79e9526c36849107b879f81a18f1bc9216fee6bca539d99e45542f51bda
-size 18409
+oid sha256:78ef264c66bdc27492ebbfc01453f7face1b2fbca1f9eb0621836481aae7c499
+size 18548
diff --git a/screenshots/86box/install-su-fold.png b/screenshots/86box/install-su-fold.png
index e46b670f..5b0363a6 100644
--- a/screenshots/86box/install-su-fold.png
+++ b/screenshots/86box/install-su-fold.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d0e4194fee2f4b9853464a41f83fe88aa5c65d9578c0d7ad86e315ebe90f2cc4
-size 21772
+oid sha256:2b6900ec5a8858fd1f12e78aca97d7c8e919c9194a2729b8cbe2727407033a7e
+size 20564
diff --git a/screenshots/86box/install-su-start.png b/screenshots/86box/install-su-start.png
new file mode 100644
index 00000000..18e80052
--- /dev/null
+++ b/screenshots/86box/install-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:920606c7ed76ffa3dacd9be76ce1134da0d6e365efdf5caa107f0510903fc04b
+size 20530
diff --git a/screenshots/86box/install-su-unin.png b/screenshots/86box/install-su-unin.png
index 3438b52b..9e493f59 100644
--- a/screenshots/86box/install-su-unin.png
+++ b/screenshots/86box/install-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:19d8eaf493f9134b8fe4fa89c916760c00e01f64d6f17ebcdcca2d2725069114
+size 18617
diff --git a/screenshots/86box/instbase-su-fold.png b/screenshots/86box/instbase-su-fold.png
index 75848fe9..2f1d49cd 100644
--- a/screenshots/86box/instbase-su-fold.png
+++ b/screenshots/86box/instbase-su-fold.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:90ff9a56778f1e60fe530830089c48ec0c46f5dcd6ecc1755c362cc7ae839689
-size 21774
+oid sha256:c420a85d11918e5cf0fa2d9849c07cb0636af382a144db80a29b008ac67b363b
+size 20553
diff --git a/screenshots/86box/instbase-su-start.png b/screenshots/86box/instbase-su-start.png
new file mode 100644
index 00000000..18e80052
--- /dev/null
+++ b/screenshots/86box/instbase-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:920606c7ed76ffa3dacd9be76ce1134da0d6e365efdf5caa107f0510903fc04b
+size 20530
diff --git a/screenshots/86box/instbase-su-unin.png b/screenshots/86box/instbase-su-unin.png
index 3438b52b..9e493f59 100644
--- a/screenshots/86box/instbase-su-unin.png
+++ b/screenshots/86box/instbase-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:19d8eaf493f9134b8fe4fa89c916760c00e01f64d6f17ebcdcca2d2725069114
+size 18617
diff --git a/screenshots/86box/menus-m-altdn.png b/screenshots/86box/menus-m-altdn.png
index b973564f..95e69470 100644
--- a/screenshots/86box/menus-m-altdn.png
+++ b/screenshots/86box/menus-m-altdn.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c3198835d6ce56caccb99633baaf23e337fe01f79d49a2212598ea7a69ff021a
-size 16818
+oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
+size 16816
diff --git a/screenshots/86box/menus-m-altup.png b/screenshots/86box/menus-m-altup.png
index 11f8b529..290bda7e 100644
--- a/screenshots/86box/menus-m-altup.png
+++ b/screenshots/86box/menus-m-altup.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4944d691ae52c5420751cd71a8e7532273bd644ee186ab14fe899a5af8789467
-size 16436
+oid sha256:2bd9800a5cf14a50f770c69b5a785724482fdb62aac9ee7de8069aa9c16eb2ed
+size 16426
diff --git a/screenshots/86box/menus-m-design.png b/screenshots/86box/menus-m-design.png
index 917b6fca..65a83dc5 100644
--- a/screenshots/86box/menus-m-design.png
+++ b/screenshots/86box/menus-m-design.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:697d37a38da9c8c668a14d77c9b3a80345dc54df17a5377d6af8dca2b1146442
-size 23484
+oid sha256:b520534eccc14ec24b1826a4d0ef7fe0c9e7d56f86befef504de05d7faa25b46
+size 23621
diff --git a/screenshots/86box/menus-m-drop.png b/screenshots/86box/menus-m-drop.png
index b973564f..95e69470 100644
--- a/screenshots/86box/menus-m-drop.png
+++ b/screenshots/86box/menus-m-drop.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c3198835d6ce56caccb99633baaf23e337fe01f79d49a2212598ea7a69ff021a
-size 16818
+oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
+size 16816
diff --git a/screenshots/86box/menus-m-file.png b/screenshots/86box/menus-m-file.png
index b63007f4..bedb5c3d 100644
--- a/screenshots/86box/menus-m-file.png
+++ b/screenshots/86box/menus-m-file.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:831607e84c478326d61e42c57f52e47c3d62f428aa562c40f540e1432e5b6558
-size 14711
+oid sha256:5805a5feb76aa404a93656e174b878dbce68fea3cb26a178b2ef6803e864611c
+size 14731
diff --git a/screenshots/86box/menus-m-run.png b/screenshots/86box/menus-m-run.png
index 55a5d8d7..08415ab8 100644
--- a/screenshots/86box/menus-m-run.png
+++ b/screenshots/86box/menus-m-run.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c240f7fa799ab61cfbadbfcf5f8b0ada8ca157aadf02aaff2767d13151b1b05f
-size 18173
+oid sha256:1cacc50a8de3c2c2594d2ab5f99adce74b3fd124f67c49652767f843a475a5b1
+size 18218
diff --git a/screenshots/86box/net-netsmk.png b/screenshots/86box/net-netsmk.png
index 59a62096..e6ed02ab 100644
--- a/screenshots/86box/net-netsmk.png
+++ b/screenshots/86box/net-netsmk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eff11e9bc8ba606e1b1c9801d53ef0aaa03d47d593ffe5d3cb2e4b92e044cce3
-size 16692
+oid sha256:2d559bf9d6b37813421ef3623c2c49427cb3356b0ad887e776f352f84f3fb54b
+size 16779
diff --git a/screenshots/86box/pardsgn-pardsgn.png b/screenshots/86box/pardsgn-pardsgn.png
index 3bf1cbb8..ab8ba75f 100644
--- a/screenshots/86box/pardsgn-pardsgn.png
+++ b/screenshots/86box/pardsgn-pardsgn.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0a766622dda77ef8d68cfb3f210d74a7585494babecb44ce98c806181ce313a6
-size 14458
+oid sha256:9d93f1f23f8ffc5fedd123ef6156982bd420a237247c7bf2b2e1a40029119945
+size 14439
diff --git a/screenshots/86box/parhlp-parhlp-l.png b/screenshots/86box/parhlp-parhlp-l.png
index c2175854..ba97be84 100644
--- a/screenshots/86box/parhlp-parhlp-l.png
+++ b/screenshots/86box/parhlp-parhlp-l.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8b2a9b275ec04d5ebba95daea4824cab032a694206b650c9f1500c8f87df4804
-size 15586
+oid sha256:1466e4d7946dc061dbb1c3485acb38ee6cbcd0930806a25b48fc355f641a2020
+size 15611
diff --git a/screenshots/86box/parhlp-parhlp.png b/screenshots/86box/parhlp-parhlp.png
index dbdd07b2..72a5f511 100644
--- a/screenshots/86box/parhlp-parhlp.png
+++ b/screenshots/86box/parhlp-parhlp.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:da365c8447dc60454576d3330595ce1768789514112fb20586e6fe37e6343f06
-size 15584
+oid sha256:c114ad172296848ae10b9e4cd85574be604f42018ebb95024199603713ac0fda
+size 15611
diff --git a/screenshots/86box/parpm-parpm.png b/screenshots/86box/parpm-parpm.png
index af27c7ba..08b4193d 100644
--- a/screenshots/86box/parpm-parpm.png
+++ b/screenshots/86box/parpm-parpm.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7a633e667e01222db13c40b0c2a76508f0030e1929b6ff7a16603b7f5b45d0e7
-size 17147
+oid sha256:032f48556016077f70afc3308a88898fee372db590b371ba381e6408472626d5
+size 17348
diff --git a/screenshots/86box/paths-paths.png b/screenshots/86box/paths-paths.png
index 74b0aa43..2b823c9e 100644
--- a/screenshots/86box/paths-paths.png
+++ b/screenshots/86box/paths-paths.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:91f9cc6604359edf4e355bc5b1eb56e44f0802124ee3a00b41fe1cf667d0ac58
-size 16581
+oid sha256:58503a736c280538316a7afa3e0d14485397b3122fc68a04a4ad94515662379d
+size 16660
diff --git a/screenshots/86box/pkgbuild-pb-built.png b/screenshots/86box/pkgbuild-pb-built.png
new file mode 100644
index 00000000..1bb324ca
--- /dev/null
+++ b/screenshots/86box/pkgbuild-pb-built.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e2f25e42b7b9bf006adad669937416d1343832368977f6f96fac4c93cb766aaf
+size 18198
diff --git a/screenshots/86box/pkgbuild-pb-files.png b/screenshots/86box/pkgbuild-pb-files.png
new file mode 100644
index 00000000..777dbaa1
--- /dev/null
+++ b/screenshots/86box/pkgbuild-pb-files.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f134f1db85a6f36eae06837472375beec9440f395a1bbbd132e1a7c84a05a7be
+size 19151
diff --git a/screenshots/86box/pkgbuild-pb-start.png b/screenshots/86box/pkgbuild-pb-start.png
new file mode 100644
index 00000000..a07c13e6
--- /dev/null
+++ b/screenshots/86box/pkgbuild-pb-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef468a476929c5011936593e2683077f4d32c8ff7cb129a946c8ecfa3af88a23
+size 15438
diff --git a/screenshots/86box/pkgsrv-ps-avail.png b/screenshots/86box/pkgsrv-ps-avail.png
new file mode 100644
index 00000000..8f11efd2
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-avail.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0855340a3827c82e84dc6fd1d1417c418cc65ec18232c46563dd8e56c77eba2e
+size 16654
diff --git a/screenshots/86box/pkgsrv-ps-done.png b/screenshots/86box/pkgsrv-ps-done.png
new file mode 100644
index 00000000..4c9e22d4
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-done.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72941aa120f26b645042c777b777228beefadb60efbd9ff49f3f6ae456af81a4
+size 15576
diff --git a/screenshots/86box/pkgsrv-ps-insta.png b/screenshots/86box/pkgsrv-ps-insta.png
new file mode 100644
index 00000000..acc0ac35
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-insta.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:300b8f8d00b837ec7257b4843654f6bfa4b10b613869d3c6c085283e11c0dd40
+size 16237
diff --git a/screenshots/86box/pkgsrv-ps-mine.png b/screenshots/86box/pkgsrv-ps-mine.png
new file mode 100644
index 00000000..63fbb375
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-mine.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:475d0c547c286d64d75f485992eee4276d0a75c8a1008025e8e0556309328c06
+size 15304
diff --git a/screenshots/86box/pkgsrv-ps-plan.png b/screenshots/86box/pkgsrv-ps-plan.png
new file mode 100644
index 00000000..86ed48b0
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-plan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b9d01a2ec12c42293c82c55b1589ba40737202f313417c9fb8c35f177105c51
+size 16689
diff --git a/screenshots/86box/pkgsrv-ps-pub.png b/screenshots/86box/pkgsrv-ps-pub.png
new file mode 100644
index 00000000..9ed20f33
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-pub.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4147a3f0131225b65faa41701a4ee2ef0a622e2a7cf8e7f660c6782114cd1b80
+size 17450
diff --git a/screenshots/86box/pkgsrv-ps-updat.png b/screenshots/86box/pkgsrv-ps-updat.png
new file mode 100644
index 00000000..2f913e65
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-updat.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b0bd334684fedfc4a767ad5d9ac90388e9a54b55f6b16b7807c4e7c9e8a07d66
+size 16018
diff --git a/screenshots/86box/pkgsrv-ps-updon.png b/screenshots/86box/pkgsrv-ps-updon.png
new file mode 100644
index 00000000..e6a5a672
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-updon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8d989cdde3af2ca80b167fb808ef6beaab9e0119ce42be0bc81eebbd53accb0f
+size 16829
diff --git a/screenshots/86box/pkgsrv-ps-uplan.png b/screenshots/86box/pkgsrv-ps-uplan.png
new file mode 100644
index 00000000..2e902c1a
--- /dev/null
+++ b/screenshots/86box/pkgsrv-ps-uplan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cf0d4373d0735f9a1926444a582642bd692a18f3a9f0a80c21ce4df6cf6e481
+size 18049
diff --git a/screenshots/86box/print-print1.png b/screenshots/86box/print-print1.png
index 9bb5a700..c82bc456 100644
--- a/screenshots/86box/print-print1.png
+++ b/screenshots/86box/print-print1.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b33259536eda180e9a524db68ed15cc0aafd7f7b79ebbe54dab04c7db3b5fe94
-size 14848
+oid sha256:ad64cbc363a6f7ec0491d1ea4e480ed1b48d2b47c09363c722cd89d2bd5d56ad
+size 14872
diff --git a/screenshots/86box/safevid-sv-desk.png b/screenshots/86box/safevid-sv-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/86box/safevid-sv-desk.png
+++ b/screenshots/86box/safevid-sv-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/86box/sdk-sdkapp.png b/screenshots/86box/sdk-sdkapp.png
index f5a79ac7..323617f2 100644
--- a/screenshots/86box/sdk-sdkapp.png
+++ b/screenshots/86box/sdk-sdkapp.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d186d8870aa1a1e4f1dde4a0136b81b04ee020ce3aa2d2ea48d09e3aaf5b204c
-size 17600
+oid sha256:af5d5842a32438d1b385e419e7fbf828e01e6b80ae36f4b22836e754d0473fb7
+size 17648
diff --git a/screenshots/86box/sdk-sdkbas.png b/screenshots/86box/sdk-sdkbas.png
index abad0582..e4fcbf90 100644
--- a/screenshots/86box/sdk-sdkbas.png
+++ b/screenshots/86box/sdk-sdkbas.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:605b7b625968172ad9230527203a6c2bb6d915bcbbac01f131943c5be2d4093c
-size 16799
+oid sha256:c8e1ec94de567e5720ad98859d35615b94e8407ecb7f360433024884925c7b44
+size 16835
diff --git a/screenshots/86box/sdk-sdkgreet.png b/screenshots/86box/sdk-sdkgreet.png
index 996f44e0..50ba0573 100644
--- a/screenshots/86box/sdk-sdkgreet.png
+++ b/screenshots/86box/sdk-sdkgreet.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:350a86a83d5be6c60adea783b58192b539e551ec5e1dee4c33df516633ad4b00
-size 16735
+oid sha256:963ffc7f70050d21d40be5d6212dc8cd74dcb0ed55dd80811a93c4f1a80d35a2
+size 16847
diff --git a/screenshots/86box/sdk-sdktbx.png b/screenshots/86box/sdk-sdktbx.png
index 80120caf..6b5272d7 100644
--- a/screenshots/86box/sdk-sdktbx.png
+++ b/screenshots/86box/sdk-sdktbx.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:68680747bb40b2de6f9aaf00d8589f107318e126eba5f4ded50b3003ec3a8e64
-size 23397
+oid sha256:ddbac3d1aa96225dcb75dbb31a10d3be96a14ea7a143b7929c1dfeb984dcf6a0
+size 23525
diff --git a/screenshots/86box/software-sw-done.png b/screenshots/86box/software-sw-done.png
new file mode 100644
index 00000000..91354225
--- /dev/null
+++ b/screenshots/86box/software-sw-done.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b67f1571788b597396f49cfd195c8d5e7189726deddefa48f6f2f814ce5683c0
+size 16954
diff --git a/screenshots/86box/software-sw-empty.png b/screenshots/86box/software-sw-empty.png
new file mode 100644
index 00000000..850fa920
--- /dev/null
+++ b/screenshots/86box/software-sw-empty.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed9c1ec9d6eb84663131a6384b223193924ec2f04317fde2f22c8f0b91616de7
+size 14118
diff --git a/screenshots/86box/software-sw-list.png b/screenshots/86box/software-sw-list.png
new file mode 100644
index 00000000..b9f37c82
--- /dev/null
+++ b/screenshots/86box/software-sw-list.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ecda14983f232c42ef31a02ca5601e45f3f69e13c32a819a69a0f7b05db6bf48
+size 15527
diff --git a/screenshots/86box/software-sw-needs.png b/screenshots/86box/software-sw-needs.png
new file mode 100644
index 00000000..e407292f
--- /dev/null
+++ b/screenshots/86box/software-sw-needs.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:03ba1b74edf52f8941dcfd7e0ebb7f2fc8db2dde71bf9336ba086b4f499460a7
+size 16342
diff --git a/screenshots/86box/software-sw-plan.png b/screenshots/86box/software-sw-plan.png
new file mode 100644
index 00000000..441de340
--- /dev/null
+++ b/screenshots/86box/software-sw-plan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8f09da4c5d11fbe26a2c29aa35ab702dcced4dbbe764a631548e6ea2ff465504
+size 18411
diff --git a/screenshots/86box/sql-sqlsmk.png b/screenshots/86box/sql-sqlsmk.png
index 0676c825..aecc479c 100644
--- a/screenshots/86box/sql-sqlsmk.png
+++ b/screenshots/86box/sql-sqlsmk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cea4451053e9ebf9b93fccf296e2a0b86d1e362e27fae329439064fff501c40c
-size 16848
+oid sha256:929127846378dfc4a48ec57d28bad1491a02d51fbeb4ae19212561e8a4bbb7e3
+size 16921
diff --git a/screenshots/86box/ucdemo-ucdemo.png b/screenshots/86box/ucdemo-ucdemo.png
index 14b8ed15..a007a82e 100644
--- a/screenshots/86box/ucdemo-ucdemo.png
+++ b/screenshots/86box/ucdemo-ucdemo.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:020814aa546db1f5f088dcf17211231362910941c89b186a13c5f2d295b8457a
-size 15818
+oid sha256:788aadd3810a1f056f07ae5c8bbdfe0413439dbedd403672a40e719aad43a654
+size 15947
diff --git a/screenshots/86box/ucide-ucide-ds.png b/screenshots/86box/ucide-ucide-ds.png
index 70d3877e..c6c86b18 100644
--- a/screenshots/86box/ucide-ucide-ds.png
+++ b/screenshots/86box/ucide-ucide-ds.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0979cbe27301ac2299b437d14fbf6850bad838a150c206f58fc81f673c01d5e4
-size 31507
+oid sha256:c377444a4b6e7369fa35a92e916c3f86d90a3d95e961f16b3c8471464776712a
+size 31429
diff --git a/screenshots/86box/ucide-ucide-ru.png b/screenshots/86box/ucide-ucide-ru.png
index 7cbd6a05..b8aefcce 100644
--- a/screenshots/86box/ucide-ucide-ru.png
+++ b/screenshots/86box/ucide-ucide-ru.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b4cfc025cde4eeda10c5761b232db9636466eb42650495b7c0472283d300f9be
-size 25237
+oid sha256:1eef572ce0f038dcadc4f2c4acefca46008c44589362cba01d43c3df697f2034
+size 25351
diff --git a/screenshots/86box/ucide-ucide-tb.png b/screenshots/86box/ucide-ucide-tb.png
index e147e726..aff9255b 100644
--- a/screenshots/86box/ucide-ucide-tb.png
+++ b/screenshots/86box/ucide-ucide-tb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:58103050f0a0174d722831a2a7771841662980826275be5a220c21816b115b94
-size 31620
+oid sha256:2a8dc683b59b689949ec7da17d049d42f8bbb16ba7ba7694d96546a747ca6462
+size 31580
diff --git a/screenshots/86box/upgrade-su-fold.png b/screenshots/86box/upgrade-su-fold.png
deleted file mode 100644
index e46b670f..00000000
--- a/screenshots/86box/upgrade-su-fold.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d0e4194fee2f4b9853464a41f83fe88aa5c65d9578c0d7ad86e315ebe90f2cc4
-size 21772
diff --git a/screenshots/86box/upgrade-su-start.png b/screenshots/86box/upgrade-su-start.png
new file mode 100644
index 00000000..0fce1288
--- /dev/null
+++ b/screenshots/86box/upgrade-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:89698205287ebf9d2f4ac573bef3ad9abc715a36f3fcd6e65d15c828581da536
+size 21106
diff --git a/screenshots/86box/upgrade-su-unin.png b/screenshots/86box/upgrade-su-unin.png
index 3438b52b..9e493f59 100644
--- a/screenshots/86box/upgrade-su-unin.png
+++ b/screenshots/86box/upgrade-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:19d8eaf493f9134b8fe4fa89c916760c00e01f64d6f17ebcdcca2d2725069114
+size 18617
diff --git a/screenshots/86box/upgrade-su-upgr.png b/screenshots/86box/upgrade-su-upgr.png
index 8c7eca7e..b58ae831 100644
--- a/screenshots/86box/upgrade-su-upgr.png
+++ b/screenshots/86box/upgrade-su-upgr.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5131d33e0640547b2f7a995c5ca2d4266c70f88c6704636018d8c82d0b013c2c
-size 22391
+oid sha256:e93258d6519c57ad8f8ab4a4408f8e0e47970e288cbffdafca2d944948b308c9
+size 21573
diff --git a/screenshots/86box/video-v-800.png b/screenshots/86box/video-v-800.png
index 3fda5f53..3ad661b2 100644
--- a/screenshots/86box/video-v-800.png
+++ b/screenshots/86box/video-v-800.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4ed7a7573e5fe8b5ec5d49ad759ea7253f6be9a79b7473a32ca8694a674d1666
-size 26164
+oid sha256:60fd02f0d1a4a0dfcf9a542c2ee25d352a6aac068ea68d06450e671df055bd54
+size 26736
diff --git a/screenshots/86box/video-v-pick.png b/screenshots/86box/video-v-pick.png
index b9bb6f8a..2d0f7057 100644
--- a/screenshots/86box/video-v-pick.png
+++ b/screenshots/86box/video-v-pick.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c37a4db6ca3c28af5e71682f886eab44d3fe5adbc92e008bc1520fb54d994eb3
-size 20076
+oid sha256:299f307b02e46be9d74b27c141734049f39f710e6cb11036d6065320c068cee7
+size 20592
diff --git a/screenshots/86box/winops-w-min.png b/screenshots/86box/winops-w-min.png
index 8bf711f6..0764e0ab 100644
--- a/screenshots/86box/winops-w-min.png
+++ b/screenshots/86box/winops-w-min.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e8b0f888a347c99bc1e523143d2b7fd5fb9fbae945286739a8c4bc5f7dff33a2
-size 18596
+oid sha256:9d27acebba6cb4ec6c56b2f4cc243456fead36d108df990672b1ed977038c4b4
+size 18646
diff --git a/screenshots/86box/winops-w-move.png b/screenshots/86box/winops-w-move.png
index 4a1084a3..b47f2f37 100644
--- a/screenshots/86box/winops-w-move.png
+++ b/screenshots/86box/winops-w-move.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:71a82787f30ceb27660c9b6e699d63d49f9ec313ed51227de108635af5ee1e05
-size 13511
+oid sha256:c521f421f2386015fde0b8549294d22b2dc41da962bf0fd1a6c54d6d8279aa1f
+size 13484
diff --git a/screenshots/dosbox/addons-addon-no.png b/screenshots/dosbox/addons-addon-no.png
new file mode 100644
index 00000000..07e38fee
--- /dev/null
+++ b/screenshots/dosbox/addons-addon-no.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:87a79d1bb9a57eba5bc045892b359d85c20a332a95de259c233ec36b1de3ef2e
+size 18229
diff --git a/screenshots/dosbox/apps-clock.png b/screenshots/dosbox/apps-clock.png
index 5cb682bd..e4a58726 100644
--- a/screenshots/dosbox/apps-clock.png
+++ b/screenshots/dosbox/apps-clock.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2d0c37c3077ab96d4908c6277bce8732919423b80a6e1c98ad1dfb7a7f178e80
-size 17914
+oid sha256:fb0cf0a293985c16f50cddb6287044fd462a35f99a88bba055ea9f4e4f9b403d
+size 18018
diff --git a/screenshots/dosbox/apps-cpanel.png b/screenshots/dosbox/apps-cpanel.png
index 4df6fcb0..eb7203d1 100644
--- a/screenshots/dosbox/apps-cpanel.png
+++ b/screenshots/dosbox/apps-cpanel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cf2fc814413dc000180596ba36f49716ec9571669a1959a2a5ce9bbfbad33f63
-size 16430
+oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
+size 16427
diff --git a/screenshots/dosbox/apps-desktop.png b/screenshots/dosbox/apps-desktop.png
index 4f9610d8..9fc09f1a 100644
--- a/screenshots/dosbox/apps-desktop.png
+++ b/screenshots/dosbox/apps-desktop.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5d14ced0f9793e53e27952b0ea5f69475f373a8c389838877ccb71ae8170818f
-size 18508
+oid sha256:0a697ed80887720ffc0e258516c4a59068e86521f79c4d9d777e5b6d703a73cf
+size 18588
diff --git a/screenshots/dosbox/apps-dvxbasic.png b/screenshots/dosbox/apps-dvxbasic.png
index adafd78e..16b7bf11 100644
--- a/screenshots/dosbox/apps-dvxbasic.png
+++ b/screenshots/dosbox/apps-dvxbasic.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ca33ce84d1585ab033f84a3e9063897633471477cd038439db44659931e86999
-size 18318
+oid sha256:c5b9aedac500ad8ff4cbbb7b59bf49751f4bde23f6e8f8a3ae9f0f34424307a1
+size 18393
diff --git a/screenshots/dosbox/apps-iconed.png b/screenshots/dosbox/apps-iconed.png
index f305a142..658cb98c 100644
--- a/screenshots/dosbox/apps-iconed.png
+++ b/screenshots/dosbox/apps-iconed.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a8ea0efb748f7f264cd034cdafbc33b5d35aa9ac0324be02d0daacf213aad97d
-size 14855
+oid sha256:35e2e173eb0155a1799f985effc2d4100ea31be802ad7e312d5ca66751601fa0
+size 14832
diff --git a/screenshots/dosbox/apps-imgview.png b/screenshots/dosbox/apps-imgview.png
index a50ad693..65619ad3 100644
--- a/screenshots/dosbox/apps-imgview.png
+++ b/screenshots/dosbox/apps-imgview.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c92d11a6a8ad8e9194751f84f37f126d728ee4789e075a2b55f17053c0d0fee1
-size 12908
+oid sha256:20216cfc2f5c629596ee18af24de18501b2431af8ee731ceb5be4427e219661b
+size 12892
diff --git a/screenshots/dosbox/apps-notepad.png b/screenshots/dosbox/apps-notepad.png
index 06a90b66..f80814d6 100644
--- a/screenshots/dosbox/apps-notepad.png
+++ b/screenshots/dosbox/apps-notepad.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6ed706f905ee45aadee7723995d1bb203a9c49de2d6e675bd96de0e470f0309a
-size 13741
+oid sha256:1ffe8d8b71867ed7b7c676213a0b715f4de49565e1e83f0694466d2d701673cb
+size 13754
diff --git a/screenshots/dosbox/apps-resedit.png b/screenshots/dosbox/apps-resedit.png
index 6778216b..4becf7fd 100644
--- a/screenshots/dosbox/apps-resedit.png
+++ b/screenshots/dosbox/apps-resedit.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e27933594c4af7e9623583d9113f7ace247b8ae92eee8bfd602d1920eb266b26
-size 14149
+oid sha256:3fc86dd4c3acc583cdb962e02b1c3a563a67bd5d84c0ed1a19314937ab191c51
+size 14168
diff --git a/screenshots/dosbox/banked-bk-back.png b/screenshots/dosbox/banked-bk-back.png
index 60f8bb96..7976b145 100644
--- a/screenshots/dosbox/banked-bk-back.png
+++ b/screenshots/dosbox/banked-bk-back.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:991866c7119668909f5e578089f241f97d42b0e97c81c48cbc6f489bcddc0781
-size 18485
+oid sha256:3425e4a35d856bef29f68f9732c7fad18669dfd3ebd1178835c0191827029cfb
+size 18540
diff --git a/screenshots/dosbox/banked-bk-desk.png b/screenshots/dosbox/banked-bk-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/dosbox/banked-bk-desk.png
+++ b/screenshots/dosbox/banked-bk-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/dosbox/banked-bk-drag.png b/screenshots/dosbox/banked-bk-drag.png
index c7870511..51540044 100644
--- a/screenshots/dosbox/banked-bk-drag.png
+++ b/screenshots/dosbox/banked-bk-drag.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b9512db9b61617ef2f25ccf302b3b600575ef5fd5b94d793c9dec199a597666d
-size 15161
+oid sha256:d815eedadc4035fea2027775eb340ae6b33b2b4f9bf90115ddacfe565096f7d9
+size 15053
diff --git a/screenshots/dosbox/banked-bk-note.png b/screenshots/dosbox/banked-bk-note.png
index 06a90b66..f8d5861d 100644
--- a/screenshots/dosbox/banked-bk-note.png
+++ b/screenshots/dosbox/banked-bk-note.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6ed706f905ee45aadee7723995d1bb203a9c49de2d6e675bd96de0e470f0309a
-size 13741
+oid sha256:cbbc99f6095370b23c0cda96486df13fb5c1e6654ff371b9a67b8fe62a116eda
+size 13750
diff --git a/screenshots/dosbox/boot-boot.png b/screenshots/dosbox/boot-boot.png
index aaa46454..c93e492a 100644
--- a/screenshots/dosbox/boot-boot.png
+++ b/screenshots/dosbox/boot-boot.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/dosbox/cpanel-cp-color.png b/screenshots/dosbox/cpanel-cp-color.png
index 00d41f40..5261386c 100644
--- a/screenshots/dosbox/cpanel-cp-color.png
+++ b/screenshots/dosbox/cpanel-cp-color.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6a695a45d25535a2cdfa489e932d221c87bc029e8e79991478ddcd1925d37b6e
-size 18280
+oid sha256:660bf211134751affcd3a05e3d5e8e8c297cda750a417873a642c2ee72861542
+size 18266
diff --git a/screenshots/dosbox/cpanel-cp-desk.png b/screenshots/dosbox/cpanel-cp-desk.png
index 76360a2e..c912aaa5 100644
--- a/screenshots/dosbox/cpanel-cp-desk.png
+++ b/screenshots/dosbox/cpanel-cp-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cfe0af167d1535d62e34ddfd9e53deaaf6441e397d815d9e4246293b7e953be4
-size 14435
+oid sha256:97022e5d29c1c26e7d47625d3fb54121173c54b9f58f884a17c451a555a3b3b3
+size 14436
diff --git a/screenshots/dosbox/cpanel-cp-joy.png b/screenshots/dosbox/cpanel-cp-joy.png
index 902d99a9..a2a740d4 100644
--- a/screenshots/dosbox/cpanel-cp-joy.png
+++ b/screenshots/dosbox/cpanel-cp-joy.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6c5abef727eff67db56905b030e31f83dea87ad20391a4af15427cffd4996256
-size 16348
+oid sha256:eb4bdad8d10caa593d3cfab42126f537714a1d2ea70e06ca64656e26fbecb785
+size 16334
diff --git a/screenshots/dosbox/cpanel-cp-mouse.png b/screenshots/dosbox/cpanel-cp-mouse.png
index 4df6fcb0..eb7203d1 100644
--- a/screenshots/dosbox/cpanel-cp-mouse.png
+++ b/screenshots/dosbox/cpanel-cp-mouse.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cf2fc814413dc000180596ba36f49716ec9571669a1959a2a5ce9bbfbad33f63
-size 16430
+oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
+size 16427
diff --git a/screenshots/dosbox/cpanel-cp-net.png b/screenshots/dosbox/cpanel-cp-net.png
index e93f245d..8a0078a8 100644
--- a/screenshots/dosbox/cpanel-cp-net.png
+++ b/screenshots/dosbox/cpanel-cp-net.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c1f37bac1331a4f7d3c58ee9bd45aed805fdd8ffa95a059487e84f5ec5fb1e63
-size 16214
+oid sha256:27f0996dda3d29cd134257c40b19a6ef215ce45162999250c704004570995426
+size 16199
diff --git a/screenshots/dosbox/cpanel-cp-print.png b/screenshots/dosbox/cpanel-cp-print.png
index bd712763..6b3a71c0 100644
--- a/screenshots/dosbox/cpanel-cp-print.png
+++ b/screenshots/dosbox/cpanel-cp-print.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0978c06a46d4d39b7d09acc02714e6f48689346f67d05f94363b0819616fa9ff
-size 15645
+oid sha256:e35a2a6c663538c45692d66e5335d808e323bda1c5f1c53e51f1124a2e6cba85
+size 15909
diff --git a/screenshots/dosbox/cpanel-cp-sound.png b/screenshots/dosbox/cpanel-cp-sound.png
new file mode 100644
index 00000000..8b22b99b
--- /dev/null
+++ b/screenshots/dosbox/cpanel-cp-sound.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8af0d409e9b907747748030acb9e6974893bdd3a426d142d679a023bfd1ac6ce
+size 17643
diff --git a/screenshots/dosbox/cpanel-cp-types.png b/screenshots/dosbox/cpanel-cp-types.png
index cc510c12..1e405480 100644
--- a/screenshots/dosbox/cpanel-cp-types.png
+++ b/screenshots/dosbox/cpanel-cp-types.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:362e4f37e6e99b0368da74c2bc15b5e08d9d6bb6267aeddbccdd908745767993
-size 23159
+oid sha256:53601bf8609488ed1a85b388e405854ec038f216b2cf6d4350d749be05195dd5
+size 23172
diff --git a/screenshots/dosbox/cpanel-cp-video.png b/screenshots/dosbox/cpanel-cp-video.png
index b0723009..e9b7cb6f 100644
--- a/screenshots/dosbox/cpanel-cp-video.png
+++ b/screenshots/dosbox/cpanel-cp-video.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b3747a43521e7552ed90512e486809dbb59cf0955b37ff21762ed28dde90ae52
-size 19765
+oid sha256:d4cc901354dfe241498eab49317b5c9c91603c4c582209dbec21973b61054c7b
+size 20198
diff --git a/screenshots/dosbox/exit-cancel.png b/screenshots/dosbox/exit-cancel.png
index 420c8e4d..e565888c 100644
--- a/screenshots/dosbox/exit-cancel.png
+++ b/screenshots/dosbox/exit-cancel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c20ee5dcc0270837cf6f2bbc99974afff411f6114670ec0f1b140f8f78bd447b
-size 14337
+oid sha256:f7508aa18520dc0675e54492895fe3e348ff659bf52b6c0c7f2efe3e6035c38d
+size 14342
diff --git a/screenshots/dosbox/exit-savechg.png b/screenshots/dosbox/exit-savechg.png
index 089b9519..74b3e466 100644
--- a/screenshots/dosbox/exit-savechg.png
+++ b/screenshots/dosbox/exit-savechg.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ceb4762d64848d9756af89143d495d13acbfd682f81f85d8941eb9b8450dbcb5
-size 14324
+oid sha256:c375e0886534255d93d2ae1ecf32683d0036cfbfb628320140a7a5fc9b019695
+size 14348
diff --git a/screenshots/dosbox/f1-h-cpanel.png b/screenshots/dosbox/f1-h-cpanel.png
index a2a88cdf..a75dbb95 100644
--- a/screenshots/dosbox/f1-h-cpanel.png
+++ b/screenshots/dosbox/f1-h-cpanel.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c608a08ba5206a0cb5bfbb9c4845087b99fae8d9dff28323fc161fc2a48a5aab
-size 22602
+oid sha256:e6fe705e41b7a4dc8b969945e455b2d79fd32d517f78b829ded6cfd44324db70
+size 22663
diff --git a/screenshots/dosbox/filedlg-fd-back.png b/screenshots/dosbox/filedlg-fd-back.png
index 007a9b08..f579cfe5 100644
--- a/screenshots/dosbox/filedlg-fd-back.png
+++ b/screenshots/dosbox/filedlg-fd-back.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:53dcaf3108803a4510e3a2c2b422b79ab147a9f4437edc9dd96c77339d10a589
-size 15236
+oid sha256:7382ad12f6411a8448df45ea184d8306423a51e0fbd8bb2d46beeaa34b43ac29
+size 15234
diff --git a/screenshots/dosbox/filedlg-fd-list.png b/screenshots/dosbox/filedlg-fd-list.png
index 25052646..eee89a60 100644
--- a/screenshots/dosbox/filedlg-fd-list.png
+++ b/screenshots/dosbox/filedlg-fd-list.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:baf10713a6c6149bf8ff58de0f14db3df40758e7d7855ad99877ab9718d37de1
-size 15446
+oid sha256:6e5d6348e54496727f261d2760cbc13a37de24c8a96a20741c479dcb25602c90
+size 15447
diff --git a/screenshots/dosbox/filedlg-fd-nordy.png b/screenshots/dosbox/filedlg-fd-nordy.png
index f4b50424..383cd199 100644
--- a/screenshots/dosbox/filedlg-fd-nordy.png
+++ b/screenshots/dosbox/filedlg-fd-nordy.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7414f2f6a1a2c09989945d3fbe2bf5e0470a1873592ca12e05911ae81514f438
+oid sha256:b6b6cd04bf85ff797c3be50ecff2fe0a3efc61e19ea397fc00c31ec7369f1111
size 15443
diff --git a/screenshots/dosbox/filedlg-fd-open.png b/screenshots/dosbox/filedlg-fd-open.png
index 156d4715..56bee79c 100644
--- a/screenshots/dosbox/filedlg-fd-open.png
+++ b/screenshots/dosbox/filedlg-fd-open.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7729d94bde811e105e739f3acd5394012840d3d007bbea98c101b7e0535ee280
-size 15194
+oid sha256:fa786de9621f9d9a39373e800572fbbce9807a7c432f0330572908f602a2060a
+size 15205
diff --git a/screenshots/dosbox/fullmode-fm-desk.png b/screenshots/dosbox/fullmode-fm-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/dosbox/fullmode-fm-desk.png
+++ b/screenshots/dosbox/fullmode-fm-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/dosbox/fullscr-fs-desk.png b/screenshots/dosbox/fullscr-fs-desk.png
index 3e95b4f3..c2e26be0 100644
--- a/screenshots/dosbox/fullscr-fs-desk.png
+++ b/screenshots/dosbox/fullscr-fs-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b28bb041c7adbdf8f345ab1d0df79d1f44519ac65a8df42eaf53567cb15fefd6
-size 17396
+oid sha256:adb402decf2e7dc5f420b0f5d479eaaa0cec605c2e4b58ed44ab6575165a0fd5
+size 17558
diff --git a/screenshots/dosbox/hang-ended.png b/screenshots/dosbox/hang-ended.png
index 3ddffdbd..903e1f9c 100644
--- a/screenshots/dosbox/hang-ended.png
+++ b/screenshots/dosbox/hang-ended.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:daccd7c5c6488ab2199a948873385f016271275e85f5b0724597802fbf131a5f
-size 15143
+oid sha256:20c8a70d7547ebefa20be0b9c5cb5e9a1be589abae0e0eea1aba70977c3fbc61
+size 15158
diff --git a/screenshots/dosbox/hang-taskmgr.png b/screenshots/dosbox/hang-taskmgr.png
index d367bbdf..3d00f119 100644
--- a/screenshots/dosbox/hang-taskmgr.png
+++ b/screenshots/dosbox/hang-taskmgr.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d5a7af537870dd735566f987649201d4f8e16e3b665b8a0345bd1a9de649d372
-size 16184
+oid sha256:b4c43dbc18bb351f0053d1862ce9ff76815cb11db0520454868b684f8efce1cc
+size 16206
diff --git a/screenshots/dosbox/install-in-desk.png b/screenshots/dosbox/install-in-desk.png
index 7786d5eb..00444c34 100644
--- a/screenshots/dosbox/install-in-desk.png
+++ b/screenshots/dosbox/install-in-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0c83a79e9526c36849107b879f81a18f1bc9216fee6bca539d99e45542f51bda
-size 18409
+oid sha256:78ef264c66bdc27492ebbfc01453f7face1b2fbca1f9eb0621836481aae7c499
+size 18548
diff --git a/screenshots/dosbox/install-su-disk.png b/screenshots/dosbox/install-su-disk.png
index f475d942..9f856b23 100644
--- a/screenshots/dosbox/install-su-disk.png
+++ b/screenshots/dosbox/install-su-disk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:80472d3fc9852f45ae4fa1b1ff29535b6586d28853fd842a585151cb95d9e78c
-size 23422
+oid sha256:2edb8d8a27f05a7b1bbba59b9dc18747da863e4e9150ac6723136914442ddb3a
+size 21310
diff --git a/screenshots/dosbox/install-su-fold.png b/screenshots/dosbox/install-su-fold.png
index 10c49f77..05bb0a78 100644
--- a/screenshots/dosbox/install-su-fold.png
+++ b/screenshots/dosbox/install-su-fold.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:16570797a20a808d62efb179973f26e9f9e5b8c17de085690c149ed1d547a7b4
-size 21789
+oid sha256:13aba99db7b50efc2c665bb9f21436b3a5fed8b4e6059a95f4b03e8db5b082ba
+size 20565
diff --git a/screenshots/dosbox/install-su-start.png b/screenshots/dosbox/install-su-start.png
new file mode 100644
index 00000000..18e80052
--- /dev/null
+++ b/screenshots/dosbox/install-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:920606c7ed76ffa3dacd9be76ce1134da0d6e365efdf5caa107f0510903fc04b
+size 20530
diff --git a/screenshots/dosbox/install-su-unin.png b/screenshots/dosbox/install-su-unin.png
index 3438b52b..d2710d7e 100644
--- a/screenshots/dosbox/install-su-unin.png
+++ b/screenshots/dosbox/install-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:62e86203241e98ef012cf0371c8faf08780ab83008ac5e79b948bd7ebeaf8863
+size 18638
diff --git a/screenshots/dosbox/install-su-wrong.png b/screenshots/dosbox/install-su-wrong.png
index 952e4585..bfdae766 100644
--- a/screenshots/dosbox/install-su-wrong.png
+++ b/screenshots/dosbox/install-su-wrong.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:15fc79b37a8cab0ddbe6e802857655c8445f20eeba3419e2a69725203bc9e2ba
-size 22170
+oid sha256:81136864ffb01f763ed7584e05e5645a0ef766283c6729957c8482d18d881827
+size 22060
diff --git a/screenshots/dosbox/instbase-su-fold.png b/screenshots/dosbox/instbase-su-fold.png
index 2356a6c1..2f1d49cd 100644
--- a/screenshots/dosbox/instbase-su-fold.png
+++ b/screenshots/dosbox/instbase-su-fold.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c3ffe7725b830def55f89dcdfebfc292b29ddcb653374b83a52a04dc25e4f3c1
-size 21738
+oid sha256:c420a85d11918e5cf0fa2d9849c07cb0636af382a144db80a29b008ac67b363b
+size 20553
diff --git a/screenshots/dosbox/instbase-su-start.png b/screenshots/dosbox/instbase-su-start.png
new file mode 100644
index 00000000..18e80052
--- /dev/null
+++ b/screenshots/dosbox/instbase-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:920606c7ed76ffa3dacd9be76ce1134da0d6e365efdf5caa107f0510903fc04b
+size 20530
diff --git a/screenshots/dosbox/instbase-su-unin.png b/screenshots/dosbox/instbase-su-unin.png
index 3438b52b..9e493f59 100644
--- a/screenshots/dosbox/instbase-su-unin.png
+++ b/screenshots/dosbox/instbase-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:19d8eaf493f9134b8fe4fa89c916760c00e01f64d6f17ebcdcca2d2725069114
+size 18617
diff --git a/screenshots/dosbox/menus-m-altdn.png b/screenshots/dosbox/menus-m-altdn.png
index b973564f..95e69470 100644
--- a/screenshots/dosbox/menus-m-altdn.png
+++ b/screenshots/dosbox/menus-m-altdn.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c3198835d6ce56caccb99633baaf23e337fe01f79d49a2212598ea7a69ff021a
-size 16818
+oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
+size 16816
diff --git a/screenshots/dosbox/menus-m-altup.png b/screenshots/dosbox/menus-m-altup.png
index 11f8b529..290bda7e 100644
--- a/screenshots/dosbox/menus-m-altup.png
+++ b/screenshots/dosbox/menus-m-altup.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4944d691ae52c5420751cd71a8e7532273bd644ee186ab14fe899a5af8789467
-size 16436
+oid sha256:2bd9800a5cf14a50f770c69b5a785724482fdb62aac9ee7de8069aa9c16eb2ed
+size 16426
diff --git a/screenshots/dosbox/menus-m-design.png b/screenshots/dosbox/menus-m-design.png
index 917b6fca..65a83dc5 100644
--- a/screenshots/dosbox/menus-m-design.png
+++ b/screenshots/dosbox/menus-m-design.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:697d37a38da9c8c668a14d77c9b3a80345dc54df17a5377d6af8dca2b1146442
-size 23484
+oid sha256:b520534eccc14ec24b1826a4d0ef7fe0c9e7d56f86befef504de05d7faa25b46
+size 23621
diff --git a/screenshots/dosbox/menus-m-drop.png b/screenshots/dosbox/menus-m-drop.png
index b973564f..95e69470 100644
--- a/screenshots/dosbox/menus-m-drop.png
+++ b/screenshots/dosbox/menus-m-drop.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c3198835d6ce56caccb99633baaf23e337fe01f79d49a2212598ea7a69ff021a
-size 16818
+oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
+size 16816
diff --git a/screenshots/dosbox/menus-m-file.png b/screenshots/dosbox/menus-m-file.png
index b63007f4..bedb5c3d 100644
--- a/screenshots/dosbox/menus-m-file.png
+++ b/screenshots/dosbox/menus-m-file.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:831607e84c478326d61e42c57f52e47c3d62f428aa562c40f540e1432e5b6558
-size 14711
+oid sha256:5805a5feb76aa404a93656e174b878dbce68fea3cb26a178b2ef6803e864611c
+size 14731
diff --git a/screenshots/dosbox/menus-m-run.png b/screenshots/dosbox/menus-m-run.png
index 55a5d8d7..08415ab8 100644
--- a/screenshots/dosbox/menus-m-run.png
+++ b/screenshots/dosbox/menus-m-run.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c240f7fa799ab61cfbadbfcf5f8b0ada8ca157aadf02aaff2767d13151b1b05f
-size 18173
+oid sha256:1cacc50a8de3c2c2594d2ab5f99adce74b3fd124f67c49652767f843a475a5b1
+size 18218
diff --git a/screenshots/dosbox/net-netsmk.png b/screenshots/dosbox/net-netsmk.png
index 59a62096..e6ed02ab 100644
--- a/screenshots/dosbox/net-netsmk.png
+++ b/screenshots/dosbox/net-netsmk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eff11e9bc8ba606e1b1c9801d53ef0aaa03d47d593ffe5d3cb2e4b92e044cce3
-size 16692
+oid sha256:2d559bf9d6b37813421ef3623c2c49427cb3356b0ad887e776f352f84f3fb54b
+size 16779
diff --git a/screenshots/dosbox/pardsgn-pardsgn.png b/screenshots/dosbox/pardsgn-pardsgn.png
index 3bf1cbb8..ab8ba75f 100644
--- a/screenshots/dosbox/pardsgn-pardsgn.png
+++ b/screenshots/dosbox/pardsgn-pardsgn.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0a766622dda77ef8d68cfb3f210d74a7585494babecb44ce98c806181ce313a6
-size 14458
+oid sha256:9d93f1f23f8ffc5fedd123ef6156982bd420a237247c7bf2b2e1a40029119945
+size 14439
diff --git a/screenshots/dosbox/parhlp-parhlp-l.png b/screenshots/dosbox/parhlp-parhlp-l.png
index c2175854..ba97be84 100644
--- a/screenshots/dosbox/parhlp-parhlp-l.png
+++ b/screenshots/dosbox/parhlp-parhlp-l.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8b2a9b275ec04d5ebba95daea4824cab032a694206b650c9f1500c8f87df4804
-size 15586
+oid sha256:1466e4d7946dc061dbb1c3485acb38ee6cbcd0930806a25b48fc355f641a2020
+size 15611
diff --git a/screenshots/dosbox/parhlp-parhlp.png b/screenshots/dosbox/parhlp-parhlp.png
index dbdd07b2..72a5f511 100644
--- a/screenshots/dosbox/parhlp-parhlp.png
+++ b/screenshots/dosbox/parhlp-parhlp.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:da365c8447dc60454576d3330595ce1768789514112fb20586e6fe37e6343f06
-size 15584
+oid sha256:c114ad172296848ae10b9e4cd85574be604f42018ebb95024199603713ac0fda
+size 15611
diff --git a/screenshots/dosbox/parpm-parpm.png b/screenshots/dosbox/parpm-parpm.png
index 2290e609..cb031cc7 100644
--- a/screenshots/dosbox/parpm-parpm.png
+++ b/screenshots/dosbox/parpm-parpm.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:45deee7eaf5ba24f1f349dc86b57d4e160abae454943cb0b65bd1fef92295de1
-size 17255
+oid sha256:87c7a3bf5f3fdc650ec7d00458828d5dca01d28be6d1ae33ac0b85ccb2023616
+size 17434
diff --git a/screenshots/dosbox/paths-paths.png b/screenshots/dosbox/paths-paths.png
index 74b0aa43..2b823c9e 100644
--- a/screenshots/dosbox/paths-paths.png
+++ b/screenshots/dosbox/paths-paths.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:91f9cc6604359edf4e355bc5b1eb56e44f0802124ee3a00b41fe1cf667d0ac58
-size 16581
+oid sha256:58503a736c280538316a7afa3e0d14485397b3122fc68a04a4ad94515662379d
+size 16660
diff --git a/screenshots/dosbox/pkgbuild-pb-built.png b/screenshots/dosbox/pkgbuild-pb-built.png
new file mode 100644
index 00000000..1bb324ca
--- /dev/null
+++ b/screenshots/dosbox/pkgbuild-pb-built.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e2f25e42b7b9bf006adad669937416d1343832368977f6f96fac4c93cb766aaf
+size 18198
diff --git a/screenshots/dosbox/pkgbuild-pb-files.png b/screenshots/dosbox/pkgbuild-pb-files.png
new file mode 100644
index 00000000..777dbaa1
--- /dev/null
+++ b/screenshots/dosbox/pkgbuild-pb-files.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f134f1db85a6f36eae06837472375beec9440f395a1bbbd132e1a7c84a05a7be
+size 19151
diff --git a/screenshots/dosbox/pkgbuild-pb-start.png b/screenshots/dosbox/pkgbuild-pb-start.png
new file mode 100644
index 00000000..a07c13e6
--- /dev/null
+++ b/screenshots/dosbox/pkgbuild-pb-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef468a476929c5011936593e2683077f4d32c8ff7cb129a946c8ecfa3af88a23
+size 15438
diff --git a/screenshots/dosbox/pkgsrv-ps-avail.png b/screenshots/dosbox/pkgsrv-ps-avail.png
new file mode 100644
index 00000000..8f11efd2
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-avail.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0855340a3827c82e84dc6fd1d1417c418cc65ec18232c46563dd8e56c77eba2e
+size 16654
diff --git a/screenshots/dosbox/pkgsrv-ps-done.png b/screenshots/dosbox/pkgsrv-ps-done.png
new file mode 100644
index 00000000..4c9e22d4
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-done.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72941aa120f26b645042c777b777228beefadb60efbd9ff49f3f6ae456af81a4
+size 15576
diff --git a/screenshots/dosbox/pkgsrv-ps-insta.png b/screenshots/dosbox/pkgsrv-ps-insta.png
new file mode 100644
index 00000000..acc0ac35
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-insta.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:300b8f8d00b837ec7257b4843654f6bfa4b10b613869d3c6c085283e11c0dd40
+size 16237
diff --git a/screenshots/dosbox/pkgsrv-ps-mine.png b/screenshots/dosbox/pkgsrv-ps-mine.png
new file mode 100644
index 00000000..63fbb375
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-mine.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:475d0c547c286d64d75f485992eee4276d0a75c8a1008025e8e0556309328c06
+size 15304
diff --git a/screenshots/dosbox/pkgsrv-ps-plan.png b/screenshots/dosbox/pkgsrv-ps-plan.png
new file mode 100644
index 00000000..86ed48b0
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-plan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b9d01a2ec12c42293c82c55b1589ba40737202f313417c9fb8c35f177105c51
+size 16689
diff --git a/screenshots/dosbox/pkgsrv-ps-pub.png b/screenshots/dosbox/pkgsrv-ps-pub.png
new file mode 100644
index 00000000..9ed20f33
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-pub.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4147a3f0131225b65faa41701a4ee2ef0a622e2a7cf8e7f660c6782114cd1b80
+size 17450
diff --git a/screenshots/dosbox/pkgsrv-ps-updat.png b/screenshots/dosbox/pkgsrv-ps-updat.png
new file mode 100644
index 00000000..2f913e65
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-updat.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b0bd334684fedfc4a767ad5d9ac90388e9a54b55f6b16b7807c4e7c9e8a07d66
+size 16018
diff --git a/screenshots/dosbox/pkgsrv-ps-updon.png b/screenshots/dosbox/pkgsrv-ps-updon.png
new file mode 100644
index 00000000..e6a5a672
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-updon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8d989cdde3af2ca80b167fb808ef6beaab9e0119ce42be0bc81eebbd53accb0f
+size 16829
diff --git a/screenshots/dosbox/pkgsrv-ps-uplan.png b/screenshots/dosbox/pkgsrv-ps-uplan.png
new file mode 100644
index 00000000..2e902c1a
--- /dev/null
+++ b/screenshots/dosbox/pkgsrv-ps-uplan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cf0d4373d0735f9a1926444a582642bd692a18f3a9f0a80c21ce4df6cf6e481
+size 18049
diff --git a/screenshots/dosbox/print-print1.png b/screenshots/dosbox/print-print1.png
index 3c1345a3..c82bc456 100644
--- a/screenshots/dosbox/print-print1.png
+++ b/screenshots/dosbox/print-print1.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:89d20bb579b7b76cd591e20fb9ac81049cdb5ae32c563341eda8b7300fce3297
-size 14853
+oid sha256:ad64cbc363a6f7ec0491d1ea4e480ed1b48d2b47c09363c722cd89d2bd5d56ad
+size 14872
diff --git a/screenshots/dosbox/safevid-sv-desk.png b/screenshots/dosbox/safevid-sv-desk.png
index aaa46454..c93e492a 100644
--- a/screenshots/dosbox/safevid-sv-desk.png
+++ b/screenshots/dosbox/safevid-sv-desk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:185883d27ca0d039b7f26acfadb1908a9c7aeb4a2cdd5216c9cca13478f2d4b5
-size 18376
+oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
+size 18426
diff --git a/screenshots/dosbox/sdk-sdkapp.png b/screenshots/dosbox/sdk-sdkapp.png
index f5a79ac7..323617f2 100644
--- a/screenshots/dosbox/sdk-sdkapp.png
+++ b/screenshots/dosbox/sdk-sdkapp.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d186d8870aa1a1e4f1dde4a0136b81b04ee020ce3aa2d2ea48d09e3aaf5b204c
-size 17600
+oid sha256:af5d5842a32438d1b385e419e7fbf828e01e6b80ae36f4b22836e754d0473fb7
+size 17648
diff --git a/screenshots/dosbox/sdk-sdkbas.png b/screenshots/dosbox/sdk-sdkbas.png
index abad0582..e4fcbf90 100644
--- a/screenshots/dosbox/sdk-sdkbas.png
+++ b/screenshots/dosbox/sdk-sdkbas.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:605b7b625968172ad9230527203a6c2bb6d915bcbbac01f131943c5be2d4093c
-size 16799
+oid sha256:c8e1ec94de567e5720ad98859d35615b94e8407ecb7f360433024884925c7b44
+size 16835
diff --git a/screenshots/dosbox/sdk-sdkgreet.png b/screenshots/dosbox/sdk-sdkgreet.png
index 996f44e0..50ba0573 100644
--- a/screenshots/dosbox/sdk-sdkgreet.png
+++ b/screenshots/dosbox/sdk-sdkgreet.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:350a86a83d5be6c60adea783b58192b539e551ec5e1dee4c33df516633ad4b00
-size 16735
+oid sha256:963ffc7f70050d21d40be5d6212dc8cd74dcb0ed55dd80811a93c4f1a80d35a2
+size 16847
diff --git a/screenshots/dosbox/sdk-sdktbx.png b/screenshots/dosbox/sdk-sdktbx.png
index 80120caf..6b5272d7 100644
--- a/screenshots/dosbox/sdk-sdktbx.png
+++ b/screenshots/dosbox/sdk-sdktbx.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:68680747bb40b2de6f9aaf00d8589f107318e126eba5f4ded50b3003ec3a8e64
-size 23397
+oid sha256:ddbac3d1aa96225dcb75dbb31a10d3be96a14ea7a143b7929c1dfeb984dcf6a0
+size 23525
diff --git a/screenshots/dosbox/software-sw-done.png b/screenshots/dosbox/software-sw-done.png
new file mode 100644
index 00000000..91354225
--- /dev/null
+++ b/screenshots/dosbox/software-sw-done.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b67f1571788b597396f49cfd195c8d5e7189726deddefa48f6f2f814ce5683c0
+size 16954
diff --git a/screenshots/dosbox/software-sw-empty.png b/screenshots/dosbox/software-sw-empty.png
new file mode 100644
index 00000000..850fa920
--- /dev/null
+++ b/screenshots/dosbox/software-sw-empty.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed9c1ec9d6eb84663131a6384b223193924ec2f04317fde2f22c8f0b91616de7
+size 14118
diff --git a/screenshots/dosbox/software-sw-list.png b/screenshots/dosbox/software-sw-list.png
new file mode 100644
index 00000000..b9f37c82
--- /dev/null
+++ b/screenshots/dosbox/software-sw-list.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ecda14983f232c42ef31a02ca5601e45f3f69e13c32a819a69a0f7b05db6bf48
+size 15527
diff --git a/screenshots/dosbox/software-sw-needs.png b/screenshots/dosbox/software-sw-needs.png
new file mode 100644
index 00000000..e407292f
--- /dev/null
+++ b/screenshots/dosbox/software-sw-needs.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:03ba1b74edf52f8941dcfd7e0ebb7f2fc8db2dde71bf9336ba086b4f499460a7
+size 16342
diff --git a/screenshots/dosbox/software-sw-plan.png b/screenshots/dosbox/software-sw-plan.png
new file mode 100644
index 00000000..441de340
--- /dev/null
+++ b/screenshots/dosbox/software-sw-plan.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8f09da4c5d11fbe26a2c29aa35ab702dcced4dbbe764a631548e6ea2ff465504
+size 18411
diff --git a/screenshots/dosbox/sql-sqlsmk.png b/screenshots/dosbox/sql-sqlsmk.png
index 0676c825..aecc479c 100644
--- a/screenshots/dosbox/sql-sqlsmk.png
+++ b/screenshots/dosbox/sql-sqlsmk.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cea4451053e9ebf9b93fccf296e2a0b86d1e362e27fae329439064fff501c40c
-size 16848
+oid sha256:929127846378dfc4a48ec57d28bad1491a02d51fbeb4ae19212561e8a4bbb7e3
+size 16921
diff --git a/screenshots/dosbox/ucdemo-ucdemo.png b/screenshots/dosbox/ucdemo-ucdemo.png
index 14b8ed15..a007a82e 100644
--- a/screenshots/dosbox/ucdemo-ucdemo.png
+++ b/screenshots/dosbox/ucdemo-ucdemo.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:020814aa546db1f5f088dcf17211231362910941c89b186a13c5f2d295b8457a
-size 15818
+oid sha256:788aadd3810a1f056f07ae5c8bbdfe0413439dbedd403672a40e719aad43a654
+size 15947
diff --git a/screenshots/dosbox/ucide-ucide-ds.png b/screenshots/dosbox/ucide-ucide-ds.png
index 70d3877e..c6c86b18 100644
--- a/screenshots/dosbox/ucide-ucide-ds.png
+++ b/screenshots/dosbox/ucide-ucide-ds.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0979cbe27301ac2299b437d14fbf6850bad838a150c206f58fc81f673c01d5e4
-size 31507
+oid sha256:c377444a4b6e7369fa35a92e916c3f86d90a3d95e961f16b3c8471464776712a
+size 31429
diff --git a/screenshots/dosbox/ucide-ucide-ru.png b/screenshots/dosbox/ucide-ucide-ru.png
index 7cbd6a05..b8aefcce 100644
--- a/screenshots/dosbox/ucide-ucide-ru.png
+++ b/screenshots/dosbox/ucide-ucide-ru.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b4cfc025cde4eeda10c5761b232db9636466eb42650495b7c0472283d300f9be
-size 25237
+oid sha256:1eef572ce0f038dcadc4f2c4acefca46008c44589362cba01d43c3df697f2034
+size 25351
diff --git a/screenshots/dosbox/ucide-ucide-tb.png b/screenshots/dosbox/ucide-ucide-tb.png
index e147e726..aff9255b 100644
--- a/screenshots/dosbox/ucide-ucide-tb.png
+++ b/screenshots/dosbox/ucide-ucide-tb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:58103050f0a0174d722831a2a7771841662980826275be5a220c21816b115b94
-size 31620
+oid sha256:2a8dc683b59b689949ec7da17d049d42f8bbb16ba7ba7694d96546a747ca6462
+size 31580
diff --git a/screenshots/dosbox/upgrade-su-fold.png b/screenshots/dosbox/upgrade-su-fold.png
deleted file mode 100644
index 10c49f77..00000000
--- a/screenshots/dosbox/upgrade-su-fold.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:16570797a20a808d62efb179973f26e9f9e5b8c17de085690c149ed1d547a7b4
-size 21789
diff --git a/screenshots/dosbox/upgrade-su-start.png b/screenshots/dosbox/upgrade-su-start.png
new file mode 100644
index 00000000..0fce1288
--- /dev/null
+++ b/screenshots/dosbox/upgrade-su-start.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:89698205287ebf9d2f4ac573bef3ad9abc715a36f3fcd6e65d15c828581da536
+size 21106
diff --git a/screenshots/dosbox/upgrade-su-unin.png b/screenshots/dosbox/upgrade-su-unin.png
index 3438b52b..d2710d7e 100644
--- a/screenshots/dosbox/upgrade-su-unin.png
+++ b/screenshots/dosbox/upgrade-su-unin.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bd1dab047ddf2342e3fafed3d7de37bcacb82b692d05c5b6a8a2e833af7ef006
-size 18398
+oid sha256:62e86203241e98ef012cf0371c8faf08780ab83008ac5e79b948bd7ebeaf8863
+size 18638
diff --git a/screenshots/dosbox/upgrade-su-upgr.png b/screenshots/dosbox/upgrade-su-upgr.png
index 8c7eca7e..b58ae831 100644
--- a/screenshots/dosbox/upgrade-su-upgr.png
+++ b/screenshots/dosbox/upgrade-su-upgr.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5131d33e0640547b2f7a995c5ca2d4266c70f88c6704636018d8c82d0b013c2c
-size 22391
+oid sha256:e93258d6519c57ad8f8ab4a4408f8e0e47970e288cbffdafca2d944948b308c9
+size 21573
diff --git a/screenshots/dosbox/video-v-800.png b/screenshots/dosbox/video-v-800.png
index 66e591a6..7ecc5797 100644
--- a/screenshots/dosbox/video-v-800.png
+++ b/screenshots/dosbox/video-v-800.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cd23dda69ce3a87fdaca7a05d02198550890f0c8a4e9ee78a4eb4487bb5e938f
-size 26521
+oid sha256:b1c866d754308a03556dbc88d9829173ba968b7493ee3e26ef0d595e66c63947
+size 26979
diff --git a/screenshots/dosbox/video-v-pick.png b/screenshots/dosbox/video-v-pick.png
index 3d8a7d5b..ddfa2e65 100644
--- a/screenshots/dosbox/video-v-pick.png
+++ b/screenshots/dosbox/video-v-pick.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6332d987d3381a24db47510daff060d793c892584b13287bc8e05fe902e6c000
-size 20106
+oid sha256:14b9358260721db22e68dde83e99a1ee2618b0e888e7163bdc445711c913750f
+size 20572
diff --git a/screenshots/dosbox/winops-w-min.png b/screenshots/dosbox/winops-w-min.png
index 8bf711f6..0764e0ab 100644
--- a/screenshots/dosbox/winops-w-min.png
+++ b/screenshots/dosbox/winops-w-min.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e8b0f888a347c99bc1e523143d2b7fd5fb9fbae945286739a8c4bc5f7dff33a2
-size 18596
+oid sha256:9d27acebba6cb4ec6c56b2f4cc243456fead36d108df990672b1ed977038c4b4
+size 18646
diff --git a/screenshots/dosbox/winops-w-move.png b/screenshots/dosbox/winops-w-move.png
index 4a1084a3..b47f2f37 100644
--- a/screenshots/dosbox/winops-w-move.png
+++ b/screenshots/dosbox/winops-w-move.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:71a82787f30ceb27660c9b6e699d63d49f9ec313ed51227de108635af5ee1e05
-size 13511
+oid sha256:c521f421f2386015fde0b8549294d22b2dc41da962bf0fd1a6c54d6d8279aa1f
+size 13484
diff --git a/screenshots/masks/banked-bk-note.mask b/screenshots/masks/banked-bk-note.mask
new file mode 100644
index 00000000..caa42381
--- /dev/null
+++ b/screenshots/masks/banked-bk-note.mask
@@ -0,0 +1,2 @@
+# Blinking text caret
+94 132 1 16
diff --git a/screenshots/masks/filedlg-fd-open.mask b/screenshots/masks/filedlg-fd-open.mask
new file mode 100644
index 00000000..22c3edf2
--- /dev/null
+++ b/screenshots/masks/filedlg-fd-open.mask
@@ -0,0 +1,2 @@
+# Blinking text caret in the Path box
+249 107 1 17
diff --git a/screenshots/masks/install-su-fold.mask b/screenshots/masks/install-su-fold.mask
index 9097904a..dc53f889 100644
--- a/screenshots/masks/install-su-fold.mask
+++ b/screenshots/masks/install-su-fold.mask
@@ -1,4 +1,4 @@
# Setup's "DVX needs about N MB" figure (grows with the package) and the rest of that line
-202 163 176 16
+202 301 176 16
# Blinking text caret after C:\DVX in the folder box
-123 211 3 18
+123 189 3 18
diff --git a/screenshots/masks/install-su-unin.mask b/screenshots/masks/install-su-unin.mask
new file mode 100644
index 00000000..aecaa1d0
--- /dev/null
+++ b/screenshots/masks/install-su-unin.mask
@@ -0,0 +1,2 @@
+# Blinking text caret after C:\DVX in the folder box
+123 189 3 18
diff --git a/screenshots/masks/instbase-su-fold.mask b/screenshots/masks/instbase-su-fold.mask
index 9097904a..dc53f889 100644
--- a/screenshots/masks/instbase-su-fold.mask
+++ b/screenshots/masks/instbase-su-fold.mask
@@ -1,4 +1,4 @@
# Setup's "DVX needs about N MB" figure (grows with the package) and the rest of that line
-202 163 176 16
+202 301 176 16
# Blinking text caret after C:\DVX in the folder box
-123 211 3 18
+123 189 3 18
diff --git a/screenshots/masks/instbase-su-unin.mask b/screenshots/masks/instbase-su-unin.mask
new file mode 100644
index 00000000..aecaa1d0
--- /dev/null
+++ b/screenshots/masks/instbase-su-unin.mask
@@ -0,0 +1,2 @@
+# Blinking text caret after C:\DVX in the folder box
+123 189 3 18
diff --git a/screenshots/masks/upgrade-su-unin.mask b/screenshots/masks/upgrade-su-unin.mask
new file mode 100644
index 00000000..aecaa1d0
--- /dev/null
+++ b/screenshots/masks/upgrade-su-unin.mask
@@ -0,0 +1,2 @@
+# Blinking text caret after C:\DVX in the folder box
+123 189 3 18
diff --git a/screenshots/masks/upgrade-su-fold.mask b/screenshots/masks/upgrade-su-upgr.mask
similarity index 83%
rename from screenshots/masks/upgrade-su-fold.mask
rename to screenshots/masks/upgrade-su-upgr.mask
index 9097904a..dc53f889 100644
--- a/screenshots/masks/upgrade-su-fold.mask
+++ b/screenshots/masks/upgrade-su-upgr.mask
@@ -1,4 +1,4 @@
# Setup's "DVX needs about N MB" figure (grows with the package) and the rest of that line
-202 163 176 16
+202 301 176 16
# Blinking text caret after C:\DVX in the folder box
-123 211 3 18
+123 189 3 18
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 00000000..3d448c97
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,16 @@
+# dvx.conf carries the console token and the relay password, and review.conf the token and the
+# path to the repository's secret key: both are per-deployment. dvx.conf.example belongs in history.
+dvx.conf
+review.conf
+data/
+review/
+*.log
+
+# The tests' keys, packages, and database, made fresh on every run
+test/data/
+
+# Secret keys never belong in history; the repository's stays on the operator's own computer.
+*.SEC
+
+# The top-level lib/ rule is for the build's libraries; this lib/ is the server's own source.
+!lib/
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 00000000..94782a85
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,149 @@
+# DVX package server
+
+The catalogue of add-on packages (`.DPK` files) that DVX's Software program browses, installs, and
+updates from, and where publishers upload them. It is a [calog](../../calog) application over
+SQLite, built on [singemaster](../../singemaster)'s shared modules: accounts, sessions, the log,
+mail, keys, the blob store, and `lib/service.lua`'s route wrapper, account routes, and operator
+checks. The two services can run in one calog process.
+
+ cp dvx.conf.example dvx.conf # and edit it
+ calog dvxserver.lua -- dvx.conf # in this folder
+
+The server finds singemaster at `../../singemaster` and calog at `../../calog` from its own folder,
+wherever calog is started; `singe=DIR` and `calog=DIR` arguments say where they are when they are
+elsewhere.
+
+## Both services in one process
+
+ calog ../../singemaster/master.lua dvxserver.lua -- singe=../../singemaster master=master.conf dvx=dvx.conf
+
+`calogArgs()` is one list for every script in a run, so each service takes its own configuration
+by name (`master=`, `dvx=`). Each listens on its own `bindPort` with its own database, and the
+shared modules are loaded once per service. `test/run.sh` runs both this way.
+
+## How it is deployed
+
+As singemaster is: plain HTTP bound to loopback, behind a reverse proxy that answers for the public
+name. One difference matters: DVX's web library speaks plain HTTP only (there is no TLS on a DOS
+machine), so the proxy must answer DVX's requests over plain HTTP as well as HTTPS. That costs no
+trust, because no package is trusted for the way it arrived: DVX installs a package only when the
+repository's signature of it checks against the repository key DVX was installed with.
+
+A client that opens a connection and then stalls holds one serving context until it gives up, so
+the proxy should time out slow requests (`client_header_timeout` and `client_body_timeout` in
+nginx) and buffer responses, which also frees a context as soon as a slow download is handed to
+the proxy.
+
+## Trust
+
+* **The repository key stays offline.** The service holds only the repository's public key
+ (`repoKey`). The operator signs an approved package on their own computer
+ (`tools/review.lua`), and the service checks that signature with the package tool before it
+ publishes the version. A stolen server can withhold or withdraw packages, but it cannot make one
+ DVX will install.
+* **Every upload is checked by the package tool** (`dvxpkg check`, the same code DVX installs
+ with): the manifest, every file against it, and the rules for where each kind of package may put
+ its files. Nothing a manifest says is taken on the uploader's word.
+* **Publishers may sign their own packages** with their own keys (`dvxpkg keygen`, or the Package
+ Builder). The catalogue shows each version's publisher key, if it has one, so a user can tell two
+ publishers apart.
+* **An id belongs to the account that first uploaded it.** Another account's upload of the same id
+ is refused, as is a second upload of a version already taken. Ids of the vendor `kpunch` are
+ DVX's own and only the operator uploads them.
+
+## The API
+
+All JSON except the downloads. Every reply carries `X-Request-Id`, which is also in the log.
+
+| Method | Path | What it does |
+|---|---|---|
+| GET | `/v1/health` | Liveness and the schema version. |
+| GET | `/v1/catalogue` | Every published package at its newest approved version: id, version, name, kind, summary, publisher, author, licence, homepage, dvx, abi, size, sha256, signer, signed, and depends (`[{id, min}]`). No session. |
+| GET | `/v1/package/` | One package's approved versions, newest first. |
+| GET | `/v1/download//` | The bytes, with `X-Content-Sha256`. Resumable: `Range` is answered. |
+| GET | `/v1/signature//` | The repository's signature of that version (a `NAME.SIG` file). |
+| POST | `/v1/updates` | `{dvx, abi, installed: [{id, version}]}`, the client's own list: answers `updates` (the newer versions) and `unknown` (ids the catalogue does not have). Nothing of it is kept. |
+| POST | `/v1/upload` | The `.DPK` as the body, from a signed-in account. Taken in as a pending version for review. DVX's Software sends it (File > Publish a Package) with the account its settings give. |
+| GET | `/v1/mine` | The account's own packages, every version with its review state and note. |
+| GET | `/v1/admin/pending` | Operator. Versions waiting for a review, oldest first. |
+| GET | `/v1/admin/download//` | Operator. Any version's bytes, to review and sign. |
+| POST | `/v1/admin/version/state` | Operator. `{package, version, state: approved or rejected, signature, note}`. Approving needs the repository's signature of the version. |
+| POST | `/v1/admin/package/state` | Operator. `{package, state: published or withdrawn}`. |
+| GET | `/v1/admin/log` | Operator. The log, searched by any field (as singemaster's). |
+| POST | `/v1/admin/key/grant`, `/v1/admin/key/revoke` | Operator. `{account, key}`; the key `admin` makes an administrator. |
+
+The account routes (`/v1/account/register`, `verify`, `login`, `logout`, `recover`, `reset`, and
+`GET /v1/account`) are singemaster's, with mail that names DVX.
+
+The operator is the console token (`adminToken`) or the session of an account holding the `admin`
+key. Withdrawing a package stops it being listed and downloaded; the copies installed keep working
+and simply stop being offered updates.
+
+## Serving contexts
+
+A DOS machine downloads slowly. The main context listens and does the housekeeping (queued mail,
+expired accounts); `serveContexts` serving contexts, each with its own database connection and
+routes, accept on its listener, so one slow download holds up only the context sending it. Each
+connection goes to one of them; with every one busy, new connections wait in the system's queue
+until one is free.
+
+## Reviewing uploads
+
+`tools/review.lua` runs on the operator's own computer, the one place the repository's secret key
+is kept. It needs singemaster's `lib/config.lua` (`singe=DIR` when singemaster is not beside this
+repository) and the package tool from a DVX build or the SDK.
+
+ calog tools/review.lua -- review.conf pending
+ calog tools/review.lua -- review.conf fetch acme.cards 1.0.0
+ calog tools/review.lua -- review.conf approve acme.cards 1.0.0 "looks fine"
+ calog tools/review.lua -- review.conf reject acme.cards 1.1.0 "the help file is missing"
+
+`review.conf` holds `server` (the service's public address), `token` (the console token or an
+administrator's session), `dvxpkg`, `secret` (the repository's secret key), and `work` (where
+fetched packages and their signatures are kept). `fetch` checks the download against the digest the
+service sent and shows the manifest and files as the package tool reads them. `approve` signs the
+fetched file itself, so what is approved is exactly what was reviewed; if that file has changed
+since, the service refuses the signature. `withdraw` and `publish` take a package off the catalogue
+and put it back.
+
+## Publishing DVX itself
+
+`make` in the DVX tree writes `dist/DVXUPD.DPK`: DVX itself as the package `kpunch.dvx` (kind
+`system`), every file of the release but the optional fonts, at this DVX's version and ABI. Packages
+of the vendor `kpunch` are the operator's, so it is uploaded with the console token and approved
+like any other:
+
+ curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/octet-stream" \
+ --data-binary @dist/DVXUPD.DPK https://packages.example.com/v1/upload
+ calog tools/review.lua -- review.conf approve kpunch.dvx 1.0.1
+
+Software then lists it first on its Updates page. DVX installs it only when the repository's
+signature checks against the key in its own `SYSTEM\KEYS`, and names the add-ons a new ABI would
+leave unable to load before anything is installed.
+
+## Configuration
+
+`dvx.conf.example` lists every key with a comment. Beyond singemaster's (`bindHost`, `bindPort`,
+`publicOrigin`, `dataDir`, `database`, `pbkdf2Iterations`, `mailFrom`, the relay settings, and
+`adminToken`), the server needs `dvxpkg` (the package tool), `repoKey` (the repository's public
+key), `serveContexts`, and `uploadMax` (the largest upload in bytes).
+
+## Tests
+
+ test/run.sh
+
+Needs `bin/host/dvxpkg` from a DVX build, calog, and singemaster beside this repository. It makes
+its own keys and packages with the package tool, starts the service on a scratch database, and runs
+two suites: `testPackages` (uploads checked and refused for the right reasons, review with the
+repository's signature, the catalogue, resumed downloads that DVX would trust, updates, withdrawing,
+an account's own packages, and a slow client not holding up the others) and `testReview` (the review
+tool end to end, including a changed file whose signature is refused). Then it starts singemaster
+and this service in one calog process and checks that both answer.
+
+ test/serve.sh DATA PORT FIXTURES DVXPKG
+
+The package server for DVX's DOS tests: the DOS test harness runs it for a script that says
+`# REQUIRES: PKGSERVER`. It makes a test repository key, gives the test DVX that key, a record of
+DVX itself, and a theme to update (installed with the package tool), uploads and approves the
+fixture packages and the next version of DVX with `tools/review.lua`, and serves them on
+`127.0.0.1:PORT`, which the emulator's guest reaches as `10.0.2.2`.
diff --git a/server/dvx.conf.example b/server/dvx.conf.example
new file mode 100644
index 00000000..31de1831
--- /dev/null
+++ b/server/dvx.conf.example
@@ -0,0 +1,37 @@
+# dvx.conf -- the DVX package server's settings (calog dvxserver.lua -- dvx.conf).
+# Every key without a comment saying otherwise is required.
+
+# Where it listens: loopback, behind the reverse proxy that answers for the public name
+bindHost = 127.0.0.1
+bindPort = 18100
+# What the world calls it (links in mail are built from this)
+publicOrigin = http://packages.dvx.example
+
+# Its files: the database, the stored packages (dataDir/blobs), and uploads arriving (dataDir/work)
+dataDir = ./data
+database = ./data/dvx.db
+
+# Password hashing work (PBKDF2 iterations)
+pbkdf2Iterations = 200000
+
+# The package tool (dvxpkg from a DVX build: bin/host/dvxpkg) and the repository's PUBLIC key
+# (REPO.KEY): approvals carry a signature made with its secret key on the operator's own computer,
+# and are checked against this. The secret key never comes near the server.
+dvxpkg = ../bin/host/dvxpkg
+repoKey = ./REPO.KEY
+
+# Connections served at once (a DOS machine downloads slowly; more wait their turn), and the
+# largest upload
+serveContexts = 4
+uploadMax = 67108864
+
+# The operator console's token (tools/review.lua uses it); empty: only administrators' sessions
+adminToken =
+
+# Mail: the sender, and the relay (empty smtpHost: mail waits in the queue)
+mailFrom = DVX Packages
+smtpHost =
+smtpPort = 587
+smtpTls = starttls
+smtpUser =
+smtpPassword =
diff --git a/server/dvxserver.lua b/server/dvxserver.lua
new file mode 100644
index 00000000..a1e8ab80
--- /dev/null
+++ b/server/dvxserver.lua
@@ -0,0 +1,500 @@
+-- dvxserver.lua -- the DVX package server: the catalogue of add-on packages that DVX's Software
+-- program browses, installs, and updates from, and where publishers upload them.
+--
+-- calog dvxserver.lua -- dvx.conf (in this folder, beside ../../singemaster)
+-- calog ../../singemaster/master.lua dvxserver.lua -- singe=../../singemaster master=master.conf dvx=dvx.conf
+--
+-- Plain HTTP on loopback, behind a reverse proxy (as singemaster: see its README). Built on
+-- singemaster's shared modules (accounts, the log, mail, keys, the blob store, and service.lua's
+-- route wrapper, account routes, and operator checks), so the two can run in one calog process.
+--
+-- Nothing a package says is taken on the uploader's word: the DVX package tool (dvxpkg check, the
+-- code DVX installs with) checks every upload. A version is published only when the operator
+-- approves it with the repository's signature, made on the operator's own computer
+-- (tools/review.lua): the service holds no secret key, only the repository's public key, with which
+-- it checks the signature it is given. DOS machines download slowly, so several contexts serve,
+-- and one download does not hold up everyone else.
+
+-- calogScript() is this file, so this folder, singemaster (../../singemaster), and calog (../../calog)
+-- are found from wherever calog was started; singe=DIR and calog=DIR in calogArgs() say where the
+-- other two are when they are elsewhere. calogArgs() is one list for every script in the run, so
+-- with another service in the same process dvx=PATH says which configuration is whose; run alone,
+-- the first plain argument is the configuration. These come before anything is loaded, so they
+-- cannot come from singemaster's modules (master.lua has its own). A serving context is a task,
+-- which no file started: it is given this folder as DVX_HERE (see the end of this file).
+local function named(name)
+ for _, value in ipairs(calogArgs()) do
+ local found = value:match("^" .. name .. "=(.+)$")
+ if found then
+ return found
+ end
+ end
+ return nil
+end
+
+local function ownArg(name)
+ local found = named(name)
+ if found then
+ return found
+ end
+ for _, value in ipairs(calogArgs()) do
+ if not value:find("=", 1, true) then
+ return value
+ end
+ end
+ return nil
+end
+
+local HERE = DVX_HERE or (calogScript():match("^(.*)[/\\]") or ".")
+local SINGE = named("singe") or (HERE .. "/../../singemaster")
+local CALOG = named("calog") or (HERE .. "/../../calog")
+
+local function need(path)
+ return load(fsRead(path), "@" .. path)()
+end
+
+local config = need(SINGE .. "/lib/config.lua")
+local db = need(SINGE .. "/lib/db.lua")
+local audit = need(SINGE .. "/lib/audit.lua")
+local web = need(SINGE .. "/lib/web.lua")
+local accounts = need(SINGE .. "/lib/accounts.lua")
+local mail = need(SINGE .. "/lib/mail.lua")
+local keys = need(SINGE .. "/lib/keys.lua")
+local blobs = need(SINGE .. "/lib/blobs.lua")
+local service = need(SINGE .. "/lib/service.lua")
+local packages = need(HERE .. "/lib/packages.lua")
+local httpd = need(CALOG .. "/examples/httpd.lua")
+local smtp = need(CALOG .. "/examples/smtp.lua")
+
+-- The keys dvx.conf must have, and the ones that are numbers
+local REQUIRED = { "bindHost", "bindPort", "publicOrigin", "dataDir", "database", "pbkdf2Iterations", "mailFrom",
+ "dvxpkg", "repoKey", "serveContexts", "uploadMax" }
+local NUMERIC = { bindPort = true, pbkdf2Iterations = true, smtpPort = true, serveContexts = true, uploadMax = true }
+
+-- Every event the service writes (audit.lua refuses a route naming one that is not here)
+local EVENTS = {
+ "account.register", "account.verify", "account.login", "account.logout", "account.read",
+ "account.recover", "account.reset", "account.sweepPending",
+ "catalogue.list", "package.get", "package.download", "package.signature", "updates.check",
+ "package.upload", "catalogue.mine",
+ "admin.pending", "admin.download", "admin.versionState", "version.approve", "version.reject",
+ "admin.packageState", "admin.log", "key.grant", "key.revoke",
+ "health", "service.start", "service.stop", "mail.sent", "mail.failed",
+}
+
+-- The service's own tables, after the shared ones (accounts, sessions, the log, rate limits, mail,
+-- keys). A package is vendor.name; each version's bytes are a blob, stored once by SHA-256.
+local SCHEMA = {
+[[
+CREATE TABLE blob (
+ id INTEGER PRIMARY KEY,
+ size INTEGER NOT NULL,
+ sha256 TEXT NOT NULL UNIQUE
+)
+]],
+[[
+CREATE TABLE package (
+ id TEXT PRIMARY KEY, -- vendor.name
+ ownerAccountId INTEGER REFERENCES account(id), -- NULL: the operator's (DVX's own)
+ state TEXT NOT NULL, -- published | withdrawn
+ createdAt INTEGER NOT NULL
+)
+]],
+[[CREATE INDEX packageOwner ON package(ownerAccountId)]],
+[[
+CREATE TABLE packageVersion (
+ packageId TEXT NOT NULL REFERENCES package(id) ON DELETE CASCADE,
+ version TEXT NOT NULL, -- major.minor.patch
+ rank INTEGER NOT NULL, -- the version as one number, for ordering
+ name TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ summary TEXT,
+ publisher TEXT,
+ author TEXT,
+ licence TEXT,
+ homepage TEXT,
+ dvx TEXT, -- the oldest DVX it runs on, or NULL
+ abi INTEGER, -- code: the ABI it was made for
+ manifest TEXT NOT NULL, -- PACKAGE.INI exactly as signed
+ signerKey TEXT, -- the publisher's key id (PACKAGE.SIG), or NULL
+ repoSig TEXT, -- the repository's detached signature, set on approval
+ blobId INTEGER NOT NULL REFERENCES blob(id),
+ state TEXT NOT NULL, -- pending | approved | rejected
+ note TEXT,
+ uploadedBy INTEGER REFERENCES account(id),
+ uploadedAt INTEGER NOT NULL,
+ reviewedAt INTEGER,
+ PRIMARY KEY (packageId, version)
+)
+]],
+[[CREATE INDEX packageVersionState ON packageVersion(state, uploadedAt)]],
+[[
+CREATE TABLE packageDepend (
+ packageId TEXT NOT NULL,
+ version TEXT NOT NULL,
+ dependId TEXT NOT NULL,
+ minVersion TEXT NOT NULL,
+ PRIMARY KEY (packageId, version, dependId),
+ FOREIGN KEY (packageId, version) REFERENCES packageVersion(packageId, version) ON DELETE CASCADE
+)
+]],
+}
+
+local MIGRATIONS = { db.join(db.SHARED_SCHEMA, db.KEY_SCHEMA, SCHEMA) }
+
+local CONFIG_KEY = "dvxserver.config" -- kv: the configuration's path, for the serving contexts
+local LISTENER_KEY = "dvxserver.listener" -- kv: the listener the serving contexts accept on
+local STOP_MS = 500 -- the longest a context waits (an accept, a pause) to see a stop
+local RETRY_MS = 20 -- the pause after an accept that failed
+local CHORE_SECONDS = 30
+local PATH_ID_VERSION = "^([%w_.]+)/([%d.]+)$" -- / in a path
+
+
+-- A serving context started by the main one (see the end of this file) is numbered, and finds the
+-- configuration and the listener in kv; the main context reads its arguments and puts them there.
+local worker = DVX_WORKER
+local configPath = worker and kvGet(CONFIG_KEY) or (ownArg("dvx") or (HERE .. "/dvx.conf"))
+local settings, configError = config.load(configPath, REQUIRED, NUMERIC)
+if not settings then
+ calogPrint("dvxserver configuration:", configError)
+ calogExit(1)
+end
+
+fsMkdir(settings.dataDir)
+local handle, schemaVersion = db.open(settings.database, MIGRATIONS)
+if not handle then
+ calogPrint("dvxserver database:", schemaVersion)
+ calogExit(1)
+end
+blobs.TABLE = "blob"
+
+local log = audit.new(handle, EVENTS)
+local server = httpd.new()
+local svc = service.new({ handle = handle, log = log, settings = settings, server = server, product = "DVX",
+ audit = audit, web = web, accounts = accounts, mail = mail, keys = keys, smtp = smtp })
+
+
+-- The operator: the console token, or an account holding the admin key. Unlike svc:operator,
+-- asking does not mark the request refused.
+local function isOperator(req, account)
+ return svc:console(req) or (account ~= nil and keys.holds(handle, account.id, keys.ADMIN))
+end
+
+
+-- / at the end of a request's path, the version as the catalogue writes it ("2.0" is
+-- 2.0.0), or nil.
+local function idVersion(req)
+ local id, text = (req.rest or ""):match(PATH_ID_VERSION)
+ local version = text and packages.versionOf(text)
+
+ if not version then
+ return nil
+ end
+ return id, version
+end
+
+
+-- A version's bytes, resumable, as id-version.dpk: the reply, the bytes sent, and whether resumed.
+local function sendVersion(req, id, version, row)
+ return web.fileReply(req, { path = blobs.path(settings.dataDir, row.sha256), size = row.size, sha256 = row.sha256 },
+ id .. "-" .. version .. ".dpk")
+end
+
+
+-- Run the package tool: its exit status and what it printed.
+local function dvxpkg(args)
+ local argv = { settings.dvxpkg }
+
+ for _, a in ipairs(args) do
+ argv[#argv + 1] = a
+ end
+ local r = procRun(argv)
+ return r.exit, r.stdout or "", r.stderr or ""
+end
+
+
+-- What the package tool says about a refused file, without its "dvxpkg: " prefix.
+local function toolReason(text)
+ return (tostring(text):gsub("^dvxpkg:%s*", ""):gsub("%s+$", ""))
+end
+
+
+svc:route("GET", "/v1/health", "health", function(req, context)
+ return web.json(200, { ok = true, service = "dvx-packages", schema = schemaVersion })
+end)
+
+svc:accountRoutes()
+svc:keyRoutes()
+svc:logRoute()
+
+
+-- Every published package at its newest approved version. Anonymous: the catalogue is public.
+svc:route("GET", "/v1/catalogue", "catalogue.list", function(req, context)
+ local list = packages.catalogue(handle)
+
+ context.detail = { count = #list }
+ return web.json(200, { packages = list, count = #list })
+end)
+
+
+-- /v1/package/ -- one package and its approved versions, newest first.
+svc:route("GET", "/v1/package/", "package.get", function(req, context)
+ local id = (req.rest or ""):match("^([%w_.]+)$")
+ local versions = id and packages.versions(handle, id)
+
+ if not versions then
+ return web.fail(404, "noPackage", "no such package")
+ end
+ context.targetType = "package"
+ context.targetId = id
+ return web.json(200, { id = id, versions = versions })
+end)
+
+
+-- /v1/download// -- the bytes of an approved version, resumable.
+svc:route("GET", "/v1/download/", "package.download", function(req, context)
+ local id, version = idVersion(req)
+ local row = id and packages.servable(handle, id, version)
+
+ if not row then
+ return web.fail(404, "noVersion", "no such package or version")
+ end
+ context.targetType = "package"
+ context.targetId = id
+ context.targetVersion = version
+ local reply, sent, resumed = sendVersion(req, id, version, row)
+ context.detail = { size = row.size, sent = sent, resumed = resumed }
+ return reply
+end)
+
+
+-- /v1/signature// -- the repository's signature of an approved version (a NAME.SIG
+-- file), which DVX checks with the repository key it was installed with.
+svc:route("GET", "/v1/signature/", "package.signature", function(req, context)
+ local id, version = idVersion(req)
+ local row = id and packages.servable(handle, id, version)
+
+ if not row or not row.repoSig then
+ return web.fail(404, "noSignature", "no such package or version")
+ end
+ context.targetType = "package"
+ context.targetId = id
+ context.targetVersion = version
+ return { status = 200, headers = { ["Content-Type"] = "text/plain" }, body = row.repoSig }
+end)
+
+
+-- What is newer than the packages a DVX has: {installed: [{id, version}]}. The list is the
+-- client's own and nothing of it is kept; the answer names nothing private.
+svc:route("POST", "/v1/updates", "updates.check", function(req, context)
+ local body, bad = web.body(req)
+ if not body then return bad end
+ if type(body.installed) ~= "table" then
+ return web.fail(400, "badRequest", "installed must be a list of {id, version}")
+ end
+ local newer, unknown = packages.updates(handle, body.installed)
+
+ context.detail = { installed = #body.installed, newer = #newer, unknown = #unknown, dvx = body.dvx, abi = body.abi }
+ return web.json(200, { updates = newer, unknown = unknown })
+end)
+
+
+-- An upload: a .DPK as the body, from a signed-in account (or the operator), taken in as a
+-- pending version for review once the package tool has checked it.
+svc:route("POST", "/v1/upload", "package.upload", function(req, context)
+ local account = svc:signedIn(req, context)
+ local operator = isOperator(req, account)
+
+ if not account and not operator then
+ context.result = "denied"
+ return web.fail(401, "noSession", "sign in first")
+ end
+ if not req.bodyFile or (req.bodySize or 0) == 0 then
+ return web.fail(400, "badRequest", "the body is the package (.dpk)")
+ end
+ local status, manifest, why = dvxpkg({ "check", req.bodyFile })
+ if status ~= 0 then
+ context.detail = { reason = toolReason(why) }
+ return web.fail(400, "badPackage", toolReason(why))
+ end
+ local _, info = dvxpkg({ "info", req.bodyFile })
+ local signer = info:match("signed by key (%x+)")
+ local blobId = blobs.put(handle, settings.dataDir, req.bodyFile)
+ local id, result, reason = packages.intake(handle, manifest, blobId, signer, (not operator) and account.id or nil)
+
+ if not id then
+ context.result = "denied"
+ context.detail = { reason = reason }
+ return web.fail(result, "refused", reason)
+ end
+ local version = result
+ context.targetType = "package"
+ context.targetId = id
+ context.targetVersion = version
+ context.description = string.format("%s uploaded %s %s for review", account and account.email or "the operator", id, version)
+ return web.json(202, { id = id, version = version, state = "pending" })
+end, { spool = { dir = settings.dataDir .. "/work", max = settings.uploadMax } })
+
+
+-- The uploader's own packages, every version with the review's word on it.
+svc:route("GET", "/v1/mine", "catalogue.mine", function(req, context)
+ local account = svc:signedIn(req, context)
+
+ if not account then
+ context.result = "denied"
+ return web.fail(401, "noSession", "sign in first")
+ end
+ return web.json(200, { versions = packages.mine(handle, account.id) })
+end)
+
+
+-- Operator: the versions waiting for review.
+svc:route("GET", "/v1/admin/pending", "admin.pending", function(req, context)
+ local ok, refusal = svc:operator(req, context)
+ if not ok then return refusal end
+ local rows = packages.pending(handle)
+
+ context.detail = { count = #rows }
+ return web.json(200, { pending = rows, count = #rows })
+end)
+
+
+-- Operator: /v1/admin/download// -- any version's bytes, to review and sign.
+svc:route("GET", "/v1/admin/download/", "admin.download", function(req, context)
+ local ok, refusal = svc:operator(req, context)
+ if not ok then return refusal end
+ local id, version = idVersion(req)
+ local row = id and packages.servable(handle, id, version, true)
+
+ if not row then
+ return web.fail(404, "noVersion", "no such package or version")
+ end
+ context.targetType = "package"
+ context.targetId = id
+ context.targetVersion = version
+ return (sendVersion(req, id, version, row))
+end)
+
+
+-- Operator: {package, version, state: approved or rejected, note, signature}. Approving takes the
+-- repository's detached signature of the package (dvxpkg sign NAME.DPK REPO.SEC -d, on the
+-- operator's own computer), which must check against the repository's public key.
+svc:route("POST", "/v1/admin/version/state", "admin.versionState", function(req, context)
+ local ok, refusal = svc:operator(req, context)
+ if not ok then return refusal end
+ local body, bad = web.body(req)
+ if not body then return bad end
+ local id = web.field(body, "package")
+ local version = packages.versionOf(web.field(body, "version"))
+ local state = web.field(body, "state")
+ local row = id and version and packages.servable(handle, id, version, true)
+
+ if state ~= "approved" and state ~= "rejected" then
+ return web.fail(400, "badState", "state is approved or rejected")
+ end
+ if not row then
+ return web.fail(404, "noVersion", "no such package or version")
+ end
+ local signature = web.field(body, "signature")
+ if state == "approved" then
+ if not signature then
+ return web.fail(400, "noSignature", "approving needs the repository's signature of the package")
+ end
+ local sigFile = blobs.workPath(settings.dataDir)
+ fsWrite(sigFile, signature)
+ local status, _, why = dvxpkg({ "verify", blobs.path(settings.dataDir, row.sha256), "-s", sigFile, settings.repoKey })
+ fsRemove(sigFile)
+ if status ~= 0 then
+ context.result = "denied"
+ context.detail = { reason = toolReason(why) }
+ return web.fail(400, "badSignature", "that is not the repository's signature of this package: " .. toolReason(why))
+ end
+ end
+ packages.review(handle, id, version, state == "approved", signature, body.note)
+ context.action = (state == "approved") and "version.approve" or "version.reject"
+ context.targetType = "package"
+ context.targetId = id
+ context.targetVersion = version
+ context.description = string.format("%s %s %s %s%s", svc:operatorName(context), state, id, version,
+ body.note and (": " .. tostring(body.note)) or "")
+ context.detail = { state = state, note = body.note }
+ return web.json(200, { package = id, version = version, state = state })
+end)
+
+
+-- Operator: {package, state: published or withdrawn}. Withdrawn packages are not listed or handed
+-- out; the copies installed keep working, and stop being offered updates.
+svc:route("POST", "/v1/admin/package/state", "admin.packageState", function(req, context)
+ local ok, refusal = svc:operator(req, context)
+ if not ok then return refusal end
+ local body, bad = web.body(req)
+ if not body then return bad end
+ local id = web.field(body, "package")
+ local state = web.field(body, "state")
+
+ if state ~= "published" and state ~= "withdrawn" then
+ return web.fail(400, "badState", "state is published or withdrawn")
+ end
+ if not id or packages.setState(handle, id, state) == 0 then
+ return web.fail(404, "noPackage", "no such package")
+ end
+ context.targetType = "package"
+ context.targetId = id
+ context.description = string.format("%s set %s to %s", svc:operatorName(context), id, state)
+ return web.json(200, { package = id, state = state })
+end)
+
+
+-- Serving. The main context listens and does the chores; serveContexts serving contexts, each with
+-- its own database handle and routes, accept on its listener, so a slow download to a DOS machine
+-- holds up only the context sending it. Each connection goes to one of them; with every one busy,
+-- new connections wait in the system's queue until one is free. An accept that fails (a client gone
+-- before it was taken, no descriptors to spare) is under pcall so it cannot end a serving context;
+-- the pause after it is outside, where calog's stop (calogExit, Ctrl-C, SIGTERM) still reaches.
+if worker then
+ local listener = kvGet(LISTENER_KEY)
+
+ while true do
+ local ok, conn = pcall(tcpAccept, listener, STOP_MS)
+ if not ok then
+ timeSleep(RETRY_MS)
+ elseif conn then
+ server:handle(conn)
+ end
+ end
+end
+
+local listener = tcpListen(settings.bindPort, { host = settings.bindHost })
+if not listener then
+ calogPrint(string.format("dvxserver: cannot listen on %s:%d", settings.bindHost, settings.bindPort))
+ calogExit(1)
+end
+kvSet(CONFIG_KEY, configPath)
+kvSet(LISTENER_KEY, listener)
+
+local script = HERE .. "/dvxserver.lua"
+for n = 1, settings.serveContexts do
+ taskSpawn("lua", string.format("DVX_WORKER = %d\nDVX_HERE = %q\nassert(load(fsRead(%q), %q))()", n, HERE, script, "@" .. script))
+end
+
+audit.write(log, { requestId = "-", action = "service.start", result = "ok",
+ description = string.format("the service started, schema %d, listening on %s:%d with %d serving contexts",
+ schemaVersion, settings.bindHost, settings.bindPort, settings.serveContexts),
+ detail = { schema = schemaVersion, host = settings.bindHost, port = settings.bindPort, contexts = settings.serveContexts } })
+calogPrint(string.format("dvx packages: schema %d, listening on %s:%d for %s (%d serving contexts)",
+ schemaVersion, settings.bindHost, settings.bindPort, settings.publicOrigin, settings.serveContexts))
+
+
+-- The chores (queued mail, expired accounts), every CHORE_SECONDS. The pause is short so a stop
+-- reaches this context promptly: calog raises it at the next native call, and a sleep does not wake.
+local lastChore = 0
+
+while true do
+ local now = math.floor(timeNow())
+
+ if now - lastChore >= CHORE_SECONDS then
+ lastChore = now
+ svc:chores()
+ end
+ timeSleep(STOP_MS)
+end
diff --git a/server/lib/packages.lua b/server/lib/packages.lua
new file mode 100644
index 00000000..9fe28757
--- /dev/null
+++ b/server/lib/packages.lua
@@ -0,0 +1,253 @@
+-- packages.lua -- the DVX package catalogue: packages, their versions, what each needs, and the
+-- review that publishes a version.
+--
+-- What a package is comes from its manifest, PACKAGE.INI, which the service never takes on the
+-- uploader's word: the DVX package tool (dvxpkg check, the same code DVX installs with) checks the
+-- manifest and every file's size and SHA-256 against it, and hands back the manifest exactly as it
+-- is in the package. Only then is it read here, for what the catalogue shows. The rules about
+-- what a package may be live in that one place, not here.
+--
+-- A package (vendor.name) belongs to the account that first uploaded it; DVX's own (kpunch.*)
+-- belong to the operator. A version is pending until the operator approves it with the
+-- repository's signature (made on the operator's own computer: the service holds no secret key)
+-- or rejects it. Only approved versions of published packages are listed or handed out.
+
+local packages = {}
+
+packages.DVX_VENDOR = "kpunch" -- DVX's own packages: the operator's to upload
+packages.VERSION_PARTS = 3 -- major.minor.patch
+packages.RANK_STEP = 65536 -- each version part is 0..65535
+
+-- The fields of [package] the catalogue keeps (the manifest is kept whole as well).
+local FIELDS = { "name", "kind", "summary", "publisher", "author", "licence", "homepage", "dvx" }
+
+
+-- A version's text ("1.2" or "1.2.0") as the catalogue writes it and its rank (for ordering), or
+-- nil. The rules are DVX's (pkgVersionParse): one to VERSION_PARTS parts, each digits, none empty.
+function packages.versionOf(text)
+ local parts = {}
+
+ for part in (tostring(text or "") .. "."):gmatch("([^.]*)%.") do
+ local n = part:match("^%d+$") and tonumber(part)
+ if not n or n >= packages.RANK_STEP then
+ return nil
+ end
+ parts[#parts + 1] = n
+ end
+ if #parts > packages.VERSION_PARTS then
+ return nil
+ end
+ while #parts < packages.VERSION_PARTS do
+ parts[#parts + 1] = 0
+ end
+ local rank = (parts[1] * packages.RANK_STEP + parts[2]) * packages.RANK_STEP + parts[3]
+ return string.format("%d.%d.%d", parts[1], parts[2], parts[3]), rank
+end
+
+
+-- A manifest's text (as dvxpkg check gives it) read into { fields..., depends = {{id, min}} }.
+-- The tool has already checked it; this only reads it.
+function packages.readManifest(text)
+ local m = { depends = {} }
+ local section = nil
+
+ -- As DVX reads it (pkgIni.c): a line starting with ; or # is a comment, section names are
+ -- any case, and keys and values lose the blanks around them
+ for line in (text .. "\n"):gmatch("([^\r\n]*)\r?\n") do
+ local stripped = line:match("^%s*(.-)%s*$")
+ local name = stripped:match("^%[%s*(.-)%s*%]$")
+ if stripped == "" or stripped:match("^[;#]") then
+ -- a blank line or a comment
+ elseif name then
+ section = name:lower()
+ else
+ local key, value = stripped:match("^([^=]-)%s*=%s*(.*)$")
+ if key and section == "package" then
+ m[key:lower()] = value
+ elseif key and section == "depends" then
+ m.depends[#m.depends + 1] = { id = key, min = value }
+ end
+ end
+ end
+ return m
+end
+
+
+-- The package id, or nil.
+function packages.get(handle, id)
+ return dbQuery(handle, "SELECT * FROM package WHERE id = ?", id)[1]
+end
+
+
+-- A version of a package as the catalogue gives it: its fields, size, digest, signer, and needs.
+local function entryOf(handle, row)
+ local entry = { id = row.packageId, version = row.version, size = row.size, sha256 = row.sha256,
+ signer = row.signerKey, abi = row.abi, signed = row.repoSig ~= nil, depends = {} }
+
+ for _, field in ipairs(FIELDS) do
+ entry[field] = row[field]
+ end
+ for _, d in ipairs(dbQuery(handle, "SELECT dependId, minVersion FROM packageDepend WHERE packageId = ? AND version = ? ORDER BY dependId",
+ row.packageId, row.version)) do
+ entry.depends[#entry.depends + 1] = { id = d.dependId, min = d.minVersion }
+ end
+ return entry
+end
+
+
+local VERSION_COLUMNS = "v.*, b.size, b.sha256"
+local VERSION_JOIN = "packageVersion v JOIN blob b ON b.id = v.blobId JOIN package p ON p.id = v.packageId"
+
+
+-- Every published package at its newest approved version, by id.
+function packages.catalogue(handle)
+ local rows = dbQuery(handle,
+ "SELECT " .. VERSION_COLUMNS .. " FROM " .. VERSION_JOIN .. " " ..
+ "WHERE p.state = 'published' AND v.state = 'approved' AND v.rank = " ..
+ "(SELECT MAX(rank) FROM packageVersion w WHERE w.packageId = v.packageId AND w.state = 'approved') " ..
+ "ORDER BY v.packageId")
+ local list = {}
+
+ for _, row in ipairs(rows) do
+ list[#list + 1] = entryOf(handle, row)
+ end
+ return list
+end
+
+
+-- A published package's approved versions, newest first (nil when it is not listed).
+function packages.versions(handle, id)
+ local pkg = packages.get(handle, id)
+
+ if not pkg or pkg.state ~= "published" then
+ return nil
+ end
+ local list = {}
+ for _, row in ipairs(dbQuery(handle, "SELECT " .. VERSION_COLUMNS .. " FROM " .. VERSION_JOIN .. " " ..
+ "WHERE v.packageId = ? AND v.state = 'approved' ORDER BY v.rank DESC", id)) do
+ list[#list + 1] = entryOf(handle, row)
+ end
+ return list
+end
+
+
+-- One version with its file, when it may be handed out: approved, of a published package (or,
+-- with any true, whatever its state, for the operator's review).
+function packages.servable(handle, id, version, any)
+ local row = dbQuery(handle, "SELECT " .. VERSION_COLUMNS .. ", p.state AS packageState FROM " .. VERSION_JOIN ..
+ " WHERE v.packageId = ? AND v.version = ?", id, version)[1]
+
+ if not row or (not any and (row.state ~= "approved" or row.packageState ~= "published")) then
+ return nil
+ end
+ return row
+end
+
+
+-- What is newer than a client's own list ({{id, version}}): the newest approved version of each
+-- package it names that is newer than the one it has, and the ids the catalogue does not know.
+function packages.updates(handle, installed)
+ local newer, unknown = {}, {}
+ local byId = {}
+
+ for _, entry in ipairs(packages.catalogue(handle)) do
+ byId[entry.id] = entry
+ end
+ for _, have in ipairs(installed or {}) do
+ local entry = byId[tostring(have.id or "")]
+ if not entry then
+ unknown[#unknown + 1] = tostring(have.id)
+ else
+ local _, haveRank = packages.versionOf(have.version)
+ local _, newRank = packages.versionOf(entry.version)
+ if haveRank and newRank > haveRank then
+ newer[#newer + 1] = entry
+ end
+ end
+ end
+ return newer, unknown
+end
+
+
+-- Take in a checked package: its manifest's text, the blob holding its bytes, the key its
+-- publisher signed it with (or nil), and the account uploading it (nil: the operator). Returns the
+-- version's id and version, or nil, an HTTP status, and why.
+function packages.intake(handle, manifestText, blobId, signerKey, accountId)
+ local m = packages.readManifest(manifestText)
+ local id = tostring(m.id or "")
+ local version, rank = packages.versionOf(m.version)
+ local vendor = id:match("^([^.]+)%.")
+
+ if id == "" or not version then
+ return nil, 400, "the manifest has no id or version"
+ end
+ if vendor == packages.DVX_VENDOR and accountId then
+ return nil, 403, id .. " is one of DVX's own packages: only the operator uploads those"
+ end
+ local pkg = packages.get(handle, id)
+ if pkg and pkg.ownerAccountId ~= accountId then
+ return nil, 403, id .. " belongs to another account"
+ end
+ if dbQuery(handle, "SELECT 1 AS x FROM packageVersion WHERE packageId = ? AND version = ?", id, version)[1] then
+ return nil, 409, id .. " " .. version .. " has been uploaded already: give the new one a higher version"
+ end
+
+ local now = math.floor(timeNow())
+ dbExec(handle, "BEGIN")
+ if not pkg then
+ dbExec(handle, "INSERT INTO package (id, ownerAccountId, state, createdAt) VALUES (?, ?, 'published', ?)", id, accountId, now)
+ end
+ dbExec(handle,
+ "INSERT INTO packageVersion (packageId, version, rank, name, kind, summary, publisher, author, licence, homepage, " ..
+ "dvx, abi, manifest, signerKey, blobId, state, uploadedBy, uploadedAt) " ..
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
+ id, version, rank, m.name or id, m.kind or "", m.summary, m.publisher, m.author, m.licence, m.homepage,
+ m.dvx, tonumber(m.abi), manifestText, signerKey, blobId, accountId, now)
+ for _, d in ipairs(m.depends) do
+ dbExec(handle, "INSERT INTO packageDepend (packageId, version, dependId, minVersion) VALUES (?, ?, ?, ?)",
+ id, version, d.id, d.min)
+ end
+ dbExec(handle, "COMMIT")
+ return id, version
+end
+
+
+-- An account's own packages, every version with the review's word on it.
+function packages.mine(handle, accountId)
+ local list = {}
+
+ for _, row in ipairs(dbQuery(handle, "SELECT v.packageId, v.version, v.name, v.state, v.note, v.uploadedAt, v.reviewedAt, p.state AS packageState " ..
+ "FROM packageVersion v JOIN package p ON p.id = v.packageId " ..
+ "WHERE p.ownerAccountId = ? ORDER BY v.packageId, v.rank DESC", accountId)) do
+ list[#list + 1] = row
+ end
+ return list
+end
+
+
+-- The versions waiting for the operator, oldest first, with who sent each.
+function packages.pending(handle)
+ return dbQuery(handle,
+ "SELECT v.packageId, v.version, v.name, v.kind, v.summary, v.signerKey, v.uploadedAt, a.email AS uploader, b.size, b.sha256 " ..
+ "FROM packageVersion v JOIN blob b ON b.id = v.blobId LEFT JOIN account a ON a.id = v.uploadedBy " ..
+ "WHERE v.state = 'pending' ORDER BY v.uploadedAt")
+end
+
+
+-- The operator's word on a version the caller has found: approved (with the repository's
+-- signature, already checked) or rejected (with a note).
+function packages.review(handle, id, version, approved, signature, note)
+ dbExec(handle, "UPDATE packageVersion SET state = ?, repoSig = ?, note = ?, reviewedAt = ? WHERE packageId = ? AND version = ?",
+ approved and "approved" or "rejected", approved and signature or nil, note, math.floor(timeNow()), id, version)
+end
+
+
+-- Withdraw a package (no longer listed or handed out; the copies installed keep working) or
+-- publish it again. The number of packages changed.
+function packages.setState(handle, id, state)
+ dbExec(handle, "UPDATE package SET state = ? WHERE id = ?", state, id)
+ return dbQuery(handle, "SELECT changes() AS n")[1].n
+end
+
+
+return packages
diff --git a/server/test/account.lua b/server/test/account.lua
new file mode 100644
index 00000000..228fec3b
--- /dev/null
+++ b/server/test/account.lua
@@ -0,0 +1,54 @@
+-- account.lua -- an account on the service under test, made as a person makes one: registered, the
+-- link in its mail followed (read from the mail queue in the database), then signed in. Loaded by
+-- the suites, and run by serve.sh for the DOS tests' account:
+--
+-- calog test/account.lua --
+
+local account = {}
+
+local JSON = { ["Content-Type"] = "application/json" }
+
+
+local function post(base, path, body)
+ local r = httpRequest({ method = "POST", url = base .. path, headers = JSON, body = jsonStringify(body) })
+ local ok, value = pcall(jsonParse, r.body or "")
+ return r.status, (ok and type(value) == "table") and value or {}
+end
+
+
+-- Make the account: its session's token and the text of the mail it was sent (nil and why when
+-- something refused). The rate limits are cleared first, since a test makes many.
+function account.make(base, database, email, password)
+ local h = dbOpen("sqlite", database)
+ dbExec(h, "DELETE FROM rateLimit")
+ dbClose(h)
+ post(base, "/v1/account/register", { email = email, password = password })
+
+ h = dbOpen("sqlite", database)
+ local rows = dbQuery(h, "SELECT body FROM mailQueue WHERE recipient = ? ORDER BY id DESC LIMIT 1", email)
+ dbClose(h)
+ if not rows[1] then
+ return nil, "no mail was queued for " .. email
+ end
+ local mail = rows[1].body
+ post(base, "/v1/account/verify", { token = mail:match("verify%?token=(%x+)") })
+ local status, session = post(base, "/v1/account/login", { email = email, password = password })
+ if status ~= 200 or not session.token then
+ return nil, "signing in answered " .. tostring(status)
+ end
+ return session.token, mail
+end
+
+
+-- Run by itself: make the account the arguments name
+local args = calogArgs()
+if not ACCOUNT_LOADED and #args == 4 and args[1]:match("^http://") then
+ local token, why = account.make(args[1], args[2], args[3], args[4])
+ if not token then
+ calogPrint("account.lua: " .. tostring(why))
+ calogExit(1)
+ end
+ calogExit(0)
+end
+
+return account
diff --git a/server/test/run.sh b/server/test/run.sh
new file mode 100755
index 00000000..3681b25c
--- /dev/null
+++ b/server/test/run.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+# run.sh -- the DVX package server's tests, as singemaster's are run: fixtures made, the server
+# started on a port of its own with a configuration of its own, the suites run against it, the
+# server stopped. Then both services in one calog process, each answering on its own port.
+#
+# server/test/run.sh (from anywhere; needs bin/host/dvxpkg from a DVX build, and calog)
+#
+# Exit status 0 when every suite passed.
+
+set -u
+cd "$(dirname "$0")/.."
+CALOG=${CALOG:-$HOME/claude/calog/bin/calog}
+SINGE=${SINGE_HOME:-$(cd ../../singemaster && pwd)}
+DVXPKG=$(cd .. && pwd)/bin/host/dvxpkg
+PORT=18101
+MASTER_PORT=18102
+ADMIN=testoperatortoken0123456789abcdef
+DATA=$(pwd)/test/data
+
+rm -rf "$DATA" && mkdir -p "$DATA/stage"
+
+# The repository's key pair (its secret stays here, as on the operator's computer) and a publisher's
+"$DVXPKG" keygen "DVX Test Repository" "$DATA/REPO" > /dev/null
+"$DVXPKG" keygen "Acme Test" "$DATA/ACME" > /dev/null
+
+# package [key]: a .DPK in $DATA
+package() {
+ local stage="$DATA/stage/$1"
+ mkdir -p "$(dirname "$stage/$3")"
+ printf 'contents of %s\n' "$3" > "$stage/$3"
+ printf '%b' "$2" > "$DATA/$1.ini"
+ "$DVXPKG" build "$DATA/$1.ini" "$stage" "$DATA/$1.dpk" ${4:-} > /dev/null || echo "fixture $1 failed"
+}
+package cards '[package]\nid = acme.cards\nname = Cards\nversion = 1.0.0\nkind = library\nsummary = Card games; for everyone\n' LIBS/ACME/CARDS/CARDS.LIB "$DATA/ACME.SEC"
+package cards2 '[package]\nid = acme.cards\nname = Cards\nversion = 1.1.0\nkind = library\n' LIBS/ACME/CARDS/CARDS.LIB
+package chess '[package]\nid = acme.chess\nname = Chess\nversion = 2.0\nkind = app\n[depends]\nacme.cards = 1.0\n' APPS/ACME/CHESS/CHESS.APP
+package dvxown '[package]\nid = kpunch.extra\nname = Extra\nversion = 1.0.0\nkind = font\n' SHARED/FONTS/KPUNCH/EXTRA.DFN
+package dice '[package]\nid = acme.dice\nname = Dice\nversion = 1.0.0\nkind = app\n' APPS/ACME/DICE/DICE.APP
+package dice2 '[package]\nid = acme.dice\nname = Dice\nversion = 1.1.0\nkind = app\n' APPS/ACME/DICE/DICE.APP
+package dice3 '[package]\nid = acme.dice\nname = Dice\nversion = 1.2.0\nkind = app\n' APPS/ACME/DICE/DICE.APP
+printf 'not a package' > "$DATA/junk.dpk"
+
+cat > "$DATA/dvx.conf" <: ask, then insist, so a stuck server cannot hang the run
+ kill "$1" 2>/dev/null
+ for i in $(seq 1 40); do
+ kill -0 "$1" 2>/dev/null || break
+ sleep 0.25
+ done
+ kill -9 "$1" 2>/dev/null
+ wait "$1" 2>/dev/null
+}
+
+wait_for() { # wait_for
+ for i in $(seq 1 40); do
+ curl -s -m 1 "http://127.0.0.1:$1/v1/health" > /dev/null 2>&1 && return 0
+ sleep 0.25
+ done
+ return 1
+}
+
+# Something already answering on a test port would be tested in place of the server under test
+for port in $PORT $MASTER_PORT; do
+ curl -s -m 1 "http://127.0.0.1:$port/" > /dev/null 2>&1 && { echo "port $port is already in use"; exit 1; }
+done
+
+"$CALOG" dvxserver.lua -- "singe=$SINGE" "$DATA/dvx.conf" > "$DATA/server.log" 2>&1 &
+SERVER=$!
+wait_for $PORT || { echo "the server did not start:"; cat "$DATA/server.log"; stop $SERVER; exit 1; }
+
+STATUS=0
+for suite in testPackages testReview; do
+ printf '%-16s ' "$suite"
+ "$CALOG" "test/$suite.lua" -- "http://127.0.0.1:$PORT" "$DATA" "$DVXPKG" "$ADMIN" "$CALOG" "$SINGE" | grep -E "^FAIL|passed,"
+ [ "${PIPESTATUS[0]}" -ne 0 ] && STATUS=1
+done
+
+stop $SERVER
+
+# Both services in one process: singemaster's master.lua and this one, each with its own settings
+mkdir -p "$DATA/master"
+cat > "$DATA/master.conf" < "$DATA/both.log" 2>&1 &
+BOTH=$!
+printf '%-16s ' "oneProcess"
+if wait_for $PORT && wait_for $MASTER_PORT \
+ && curl -s "http://127.0.0.1:$PORT/v1/health" | grep -q '"dvx-packages"' \
+ && curl -s "http://127.0.0.1:$MASTER_PORT/v1/health" | grep -q '"singe-master"'; then
+ echo "2 passed, 0 failed"
+else
+ echo "FAIL both services in one process"; cat "$DATA/both.log"; STATUS=1
+fi
+stop $BOTH
+exit $STATUS
diff --git a/server/test/serve.sh b/server/test/serve.sh
new file mode 100755
index 00000000..21df12b9
--- /dev/null
+++ b/server/test/serve.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+# serve.sh DATA PORT FIXTURES DVXPKG USER PASSWORD -- the package server for the DOS tests (runDos.sh starts it
+# for a "# REQUIRES: PKGSERVER" script and stops it by its PID). In DATA, from nothing:
+#
+# REPO.KEY / REPO.SEC a test repository key, made for this run
+# guest/ what goes into the test DVX before it starts: the repository's public key
+# in SYSTEM\KEYS, DVX's own record (kpunch.dvx, this DVX's version), and Sky
+# 1.0 (FIXTURES/sky), both installed with the package tool
+# server/ the DVX package server on 127.0.0.1:PORT (the guest's 10.0.2.2), holding
+# Star (FIXTURES/star, which needs Sky), Sky 1.1 (FIXTURES/sky11), and the
+# next patch version of DVX itself, each uploaded and approved with
+# tools/review.lua as an operator would, and an account (USER, PASSWORD)
+# the guest publishes with, registered and its mail link followed
+# ready written once all that is done; server.log the server's output
+#
+# Then it waits for the server, and stops it when it is stopped. It exits at once, with a reason,
+# when calog or singemaster is not there.
+
+set -u
+cd "$(dirname "$0")/.."
+CALOG=${CALOG:-$HOME/claude/calog/bin/calog}
+SINGE=${SINGE_HOME:-$(cd ../../singemaster 2>/dev/null && pwd)}
+DATA=$1
+PORT=$2
+FIX=$3
+DVXPKG=$4
+USER_EMAIL=$5
+USER_PASSWORD=$6
+GUEST=$DATA/guest
+ADMIN=$(head -c 16 /dev/urandom | xxd -p)
+SERVER=""
+
+fail() {
+ echo "serve.sh: $*" >&2
+ exit 1
+}
+
+stop() {
+ if [ -n "$SERVER" ]; then
+ kill "$SERVER" 2>/dev/null
+ wait "$SERVER" 2>/dev/null
+ SERVER=""
+ fi
+}
+
+trap 'stop; exit 0' TERM INT
+trap stop EXIT
+
+[ -x "$CALOG" ] || fail "calog is not at $CALOG (set CALOG)"
+[ -n "$SINGE" ] && [ -f "$SINGE/lib/service.lua" ] || fail "singemaster is not beside this repository (set SINGE_HOME)"
+[ -x "$DVXPKG" ] || fail "the package tool is not at $DVXPKG"
+
+rm -rf "$DATA" && mkdir -p "$DATA/server" "$DATA/empty" "$DATA/next/SYSTEM" "$GUEST/SYSTEM/KEYS"
+
+# The repository's key, and the guest's trust in it
+"$DVXPKG" keygen "DVX Test Repository" "$DATA/REPO" > /dev/null || fail "keygen failed"
+cp "$DATA/REPO.KEY" "$GUEST/SYSTEM/KEYS/TESTREPO.KEY"
+
+# The guest's DVX record (a package of no files) and Sky 1.0, as the package tool installs them
+"$DVXPKG" dvx "$DATA/dvx.ini" || fail "the package tool did not write DVX's template"
+"$DVXPKG" build "$DATA/dvx.ini" "$DATA/empty" "$DATA/dvxnow.dpk" > /dev/null || fail "DVX's record package did not build"
+"$DVXPKG" sign "$DATA/dvxnow.dpk" "$DATA/REPO.SEC" -d > /dev/null || fail "DVX's record package was not signed"
+"$DVXPKG" install "$DATA/dvxnow.dpk" -r "$GUEST" -y > /dev/null || fail "DVX's record was not installed"
+"$DVXPKG" build "$FIX/sky/PACKAGE.INI" "$FIX/sky" "$DATA/sky.dpk" > /dev/null || fail "Sky 1.0 did not build"
+"$DVXPKG" install "$DATA/sky.dpk" -r "$GUEST" -y > /dev/null || fail "Sky 1.0 was not installed"
+
+# What the server will hold: Star, Sky 1.1, and DVX's next patch version (one file of its own)
+"$DVXPKG" build "$FIX/star/PACKAGE.INI" "$FIX/star" "$DATA/star.dpk" > /dev/null || fail "Star did not build"
+"$DVXPKG" build "$FIX/sky11/PACKAGE.INI" "$FIX/sky11" "$DATA/sky11.dpk" > /dev/null || fail "Sky 1.1 did not build"
+IFS=. read -r MAJOR MINOR PATCH < <(sed -n 's/^version *= *\([0-9.]*\).*/\1/p' "$DATA/dvx.ini")
+sed "s/^version *= *[0-9.]*/version = $MAJOR.$MINOR.$((PATCH + 1))/" "$DATA/dvx.ini" > "$DATA/dvxnext.ini"
+printf 'The DVX update of the DOS tests\r\n' > "$DATA/next/SYSTEM/UPDTEST.TXT"
+"$DVXPKG" build "$DATA/dvxnext.ini" "$DATA/next" "$DATA/dvxnext.dpk" > /dev/null || fail "DVX's next version did not build"
+
+cat > "$DATA/dvx.conf" < "$DATA/review.conf" < "$DATA/server.log" 2>&1 &
+SERVER=$!
+
+for i in $(seq 1 40); do
+ curl -s -m 1 "http://127.0.0.1:$PORT/v1/health" > /dev/null 2>&1 && break
+ sleep 0.25
+done
+
+curl -s -m 1 "http://127.0.0.1:$PORT/v1/health" > /dev/null 2>&1 || fail "the server did not start (see $DATA/server.log)"
+
+# Each uploaded by the operator and approved with the review tool
+for pkg in star sky11 dvxnext; do
+ status=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/octet-stream" --data-binary "@$DATA/$pkg.dpk" "http://127.0.0.1:$PORT/v1/upload")
+ [ "$status" = "202" ] || fail "uploading $pkg answered $status"
+ read -r id version _ < <("$DVXPKG" info "$DATA/$pkg.dpk")
+ "$CALOG" tools/review.lua -- "singe=$SINGE" "$DATA/review.conf" approve "$id" "$version" > "$DATA/review-$pkg.log" 2>&1 || fail "approving $pkg failed (see $DATA/review-$pkg.log)"
+done
+
+# The account the guest publishes with
+"$CALOG" test/account.lua -- "http://127.0.0.1:$PORT" "$DATA/server/dvx.db" "$USER_EMAIL" "$USER_PASSWORD" > "$DATA/account.log" 2>&1 || fail "the account was not made (see $DATA/account.log)"
+
+touch "$DATA/ready"
+wait "$SERVER"
diff --git a/server/test/testPackages.lua b/server/test/testPackages.lua
new file mode 100644
index 00000000..2db84cf2
--- /dev/null
+++ b/server/test/testPackages.lua
@@ -0,0 +1,199 @@
+-- testPackages.lua -- the package server end to end: uploads checked by the package tool and
+-- refused for the right reasons, review with the repository's signature (made here, as on the
+-- operator's own computer), the public catalogue, downloads (resumed) that check against the
+-- signature, updates, withdrawing, an account's own packages, and a slow client not holding up
+-- the others.
+--
+-- calog test/testPackages.lua --
+
+local BASE = calogArgs()[1]
+local DATA = calogArgs()[2]
+local DVXPKG = calogArgs()[3]
+local ADMIN = calogArgs()[4]
+local DB = DATA .. "/server/dvx.db"
+
+local pass, fail = 0, 0
+local function check(name, got, want)
+ if got == want then pass = pass + 1 else fail = fail + 1
+ calogPrint("FAIL", name, "got", tostring(got), "want", tostring(want)) end
+end
+
+local function call(method, path, body, token, raw, headers)
+ headers = headers or {}
+ headers["Content-Type"] = raw and "application/octet-stream" or "application/json"
+ if token then headers["Authorization"] = "Bearer " .. token end
+ local r = httpRequest({ method = method, url = BASE .. path, headers = headers,
+ body = raw and body or (body and jsonStringify(body) or nil) })
+ local parsed = {}
+ if r.body and r.body ~= "" then
+ local ok, value = pcall(jsonParse, r.body)
+ if ok and type(value) == "table" then parsed = value end
+ end
+ return r.status, parsed, r
+end
+
+ACCOUNT_LOADED = true
+local accounts = load(fsRead("test/account.lua"), "@test/account.lua")()
+
+local function account(tag)
+ local mailbox = tag .. "-" .. cryptoHexEncode(cryptoRandomBytes(4)) .. "@example.com"
+ local token, mail = accounts.make(BASE, DB, mailbox, "a good long password")
+ check(tag .. ": the mail names DVX", tostring(mail):find("DVX account", 1, true) ~= nil, true)
+ return token, mailbox
+end
+
+local function dpk(name)
+ return fsRead(DATA .. "/" .. name .. ".dpk")
+end
+
+-- The repository's signature of a package's bytes, as the operator makes it on their own computer
+local function repoSign(bytes)
+ local path = DATA .. "/review.dpk"
+ fsWrite(path, bytes)
+ local r = procRun({ DVXPKG, "sign", path, DATA .. "/REPO.SEC", "-d" })
+ check("the operator's tool signs", r.exit, 0)
+ return fsRead(DATA .. "/review.SIG")
+end
+
+
+-- The service
+local status, health = call("GET", "/v1/health")
+check("health", status, 200)
+check("health names the service", health.service, "dvx-packages")
+
+-- Uploads: a session, a package, and the tool's word on it
+local uploader, uploaderMail = account("up")
+local stranger = account("other")
+check("an upload needs a session", (call("POST", "/v1/upload", dpk("cards"), nil, true)), 401)
+local junkStatus, junk = call("POST", "/v1/upload", dpk("junk"), uploader, true)
+check("something that is no package is refused", junkStatus, 400)
+check("with the tool's reason", junk.error, "badPackage")
+check("an empty upload is refused", (call("POST", "/v1/upload", "", uploader, true)), 400)
+
+local upStatus, up = call("POST", "/v1/upload", dpk("cards"), uploader, true)
+check("a package is taken in", upStatus, 202)
+check("as acme.cards", up.id, "acme.cards")
+check("its version", up.version, "1.0.0")
+check("pending review", up.state, "pending")
+check("the same version again is refused", (call("POST", "/v1/upload", dpk("cards"), uploader, true)), 409)
+check("another account cannot add to it", (call("POST", "/v1/upload", dpk("cards2"), stranger, true)), 403)
+local ownStatus, own = call("POST", "/v1/upload", dpk("dvxown"), uploader, true)
+check("DVX's own packages are the operator's", ownStatus, 403)
+check("and it says so", tostring(own.message):find("operator", 1, true) ~= nil, true)
+check("the operator may upload one", (call("POST", "/v1/upload", dpk("dvxown"), ADMIN, true)), 202)
+check("the uploader's second version", (call("POST", "/v1/upload", dpk("cards2"), uploader, true)), 202)
+check("a package that needs another", (call("POST", "/v1/upload", dpk("chess"), uploader, true)), 202)
+
+-- Nothing is listed before review
+local _, cat = call("GET", "/v1/catalogue")
+check("nothing listed before review", cat.count, 0)
+local _, mine = call("GET", "/v1/mine", nil, uploader)
+check("the uploader sees their versions", #(mine.versions or {}), 3)
+check("pending", mine.versions and mine.versions[1].state, "pending")
+check("mine needs a session", (call("GET", "/v1/mine")), 401)
+
+-- Review: the operator, the bytes, the repository's signature
+check("pending is the operator's", (call("GET", "/v1/admin/pending", nil, uploader)), 401)
+local _, pend = call("GET", "/v1/admin/pending", nil, ADMIN)
+check("four waiting", pend.count, 4)
+local dlStatus, _, dl = call("GET", "/v1/admin/download/acme.cards/1.0.0", nil, ADMIN)
+check("the operator downloads a pending version", dlStatus, 200)
+check("its bytes are the upload's", dl.body == dpk("cards"), true)
+check("a pending version is not public", (call("GET", "/v1/download/acme.cards/1.0.0")), 404)
+
+local good = repoSign(dl.body)
+check("approving needs a signature", (call("POST", "/v1/admin/version/state", { package = "acme.cards", version = "1.0.0", state = "approved" }, ADMIN)), 400)
+-- A signature by another key (the publisher's) is not the repository's
+fsWrite(DATA .. "/review.dpk", dl.body)
+procRun({ DVXPKG, "sign", DATA .. "/review.dpk", DATA .. "/ACME.SEC", "-d" })
+local badSigStatus, badSig = call("POST", "/v1/admin/version/state",
+ { package = "acme.cards", version = "1.0.0", state = "approved", signature = fsRead(DATA .. "/review.SIG") }, ADMIN)
+check("another key's signature is refused", badSigStatus, 400)
+check("as not the repository's", badSig.error, "badSignature")
+check("approved with the repository's", (call("POST", "/v1/admin/version/state",
+ { package = "acme.cards", version = "1.0.0", state = "approved", signature = good }, ADMIN)), 200)
+check("rejected with a note", (call("POST", "/v1/admin/version/state",
+ { package = "acme.cards", version = "1.1.0", state = "rejected", note = "needs work" }, ADMIN)), 200)
+local _, _, chessDl = call("GET", "/v1/admin/download/acme.chess/2.0.0", nil, ADMIN)
+check("chess approved too", (call("POST", "/v1/admin/version/state",
+ { package = "acme.chess", version = "2.0", state = "approved", signature = repoSign(chessDl.body) }, ADMIN)), 200)
+check("no such version", (call("POST", "/v1/admin/version/state", { package = "acme.none", version = "1.0", state = "rejected" }, ADMIN)), 404)
+
+-- The public catalogue
+_, cat = call("GET", "/v1/catalogue")
+check("two packages listed", cat.count, 2)
+local cards = cat.packages and cat.packages[1] or {}
+local chess = cat.packages and cat.packages[2] or {}
+check("cards first", cards.id, "acme.cards")
+check("at the approved version", cards.version, "1.0.0")
+check("its summary, ; and all", cards.summary, "Card games; for everyone")
+check("its kind", cards.kind, "library")
+check("its publisher's key", cards.signer ~= nil, true)
+check("signed by the repository", cards.signed, true)
+check("its digest", cards.sha256, cryptoHashSha256(dpk("cards")))
+check("chess needs cards", chess.depends and chess.depends[1] and chess.depends[1].id, "acme.cards")
+check("from 1.0.0", chess.depends and chess.depends[1] and chess.depends[1].min, "1.0.0")
+local _, versions = call("GET", "/v1/package/acme.cards")
+check("its versions: only the approved", #(versions.versions or {}), 1)
+check("an unknown package", (call("GET", "/v1/package/acme.none")), 404)
+_, mine = call("GET", "/v1/mine", nil, uploader)
+local noted = false
+for _, v in ipairs(mine.versions or {}) do
+ noted = noted or (v.version == "1.1.0" and v.state == "rejected" and v.note == "needs work")
+end
+check("the uploader sees the rejection and its note", noted, true)
+
+-- Downloads: whole, resumed, and checked against the repository's signature
+local getStatus, _, got = call("GET", "/v1/download/acme.cards/1.0.0")
+check("a download", getStatus, 200)
+check("the bytes", got.body == dpk("cards"), true)
+check("their digest in a header", got.headers["x-content-sha256"], cryptoHashSha256(dpk("cards")))
+local partStatus, _, part = call("GET", "/v1/download/acme.cards/1.0.0", nil, nil, false, { Range = "bytes=10-" })
+check("a resumed download", partStatus, 206)
+check("the rest of it", part.body == dpk("cards"):sub(11), true)
+local sigStatus, _, sig = call("GET", "/v1/signature/acme.cards/1.0.0")
+check("its signature", sigStatus, 200)
+fsWrite(DATA .. "/fetched.dpk", got.body)
+fsWrite(DATA .. "/fetched.SIG", sig.body)
+check("DVX would trust it", procRun({ DVXPKG, "verify", DATA .. "/fetched.dpk", "-s", DATA .. "/fetched.SIG", DATA .. "/REPO.KEY" }).exit, 0)
+check("no signature for a rejected version", (call("GET", "/v1/signature/acme.cards/1.1.0")), 404)
+check("a bad download path", (call("GET", "/v1/download/acme.cards")), 404)
+check("a version as written: 2.0 is 2.0.0", (call("GET", "/v1/download/acme.chess/2.0")), 200)
+check("a version that is no version", (call("GET", "/v1/download/acme.chess/2..0")), 404)
+
+-- Updates: what is newer than what a DVX has
+local _, upd = call("POST", "/v1/updates", { dvx = "1.0.0", abi = 10, installed = { { id = "acme.cards", version = "0.9" },
+ { id = "acme.chess", version = "2.0.0" }, { id = "acme.gone", version = "1.0" } } })
+check("one update", #(upd.updates or {}), 1)
+check("for cards", upd.updates and upd.updates[1] and upd.updates[1].id, "acme.cards")
+check("one unknown", upd.unknown and upd.unknown[1], "acme.gone")
+check("a list is needed", (call("POST", "/v1/updates", { installed = "x" })), 400)
+
+-- Withdrawn: not listed, not handed out; then back
+check("withdrawn", (call("POST", "/v1/admin/package/state", { package = "acme.chess", state = "withdrawn" }, ADMIN)), 200)
+_, cat = call("GET", "/v1/catalogue")
+check("one listed", cat.count, 1)
+check("not handed out", (call("GET", "/v1/download/acme.chess/2.0.0")), 404)
+check("published again", (call("POST", "/v1/admin/package/state", { package = "acme.chess", state = "published" }, ADMIN)), 200)
+check("no such package", (call("POST", "/v1/admin/package/state", { package = "acme.none", state = "withdrawn" }, ADMIN)), 404)
+
+-- An administrator is an account holding the admin key
+local _, me = call("GET", "/v1/account", nil, uploader)
+check("the account", me.email, uploaderMail)
+check("granted admin", (call("POST", "/v1/admin/key/grant", { account = uploaderMail, key = "admin" }, ADMIN)), 200)
+check("an administrator sees what is pending", (call("GET", "/v1/admin/pending", nil, uploader)), 200)
+local _, logRows = call("GET", "/v1/admin/log?event=version.", nil, ADMIN)
+check("the log has the reviews", (logRows.count or 0) >= 3, true)
+
+-- A slow client holds up only its own context: a request that never ends its headers, and a
+-- second request answered meanwhile
+local host, port = BASE:match("^http://([^:/]+):(%d+)")
+local slow = tcpConnect(host, tonumber(port))
+tcpSend(slow, "GET /v1/catalogue HTTP/1.1\r\nHost: x\r\n")
+local started = timeMonotonic()
+check("answered beside a slow client", (call("GET", "/v1/health")), 200)
+check("at once", timeMonotonic() - started < 2, true)
+tcpClose(slow)
+
+calogPrint(string.format("%d passed, %d failed", pass, fail))
+calogExit(fail == 0 and 0 or 1)
diff --git a/server/test/testReview.lua b/server/test/testReview.lua
new file mode 100644
index 00000000..a090fadc
--- /dev/null
+++ b/server/test/testReview.lua
@@ -0,0 +1,111 @@
+-- testReview.lua -- the operator's review tool (tools/review.lua) against the running service: what
+-- waits, a fetch and the package tool's word on it, an approval signed with the repository's key
+-- that DVX would trust, a rejection, withdrawing and publishing again, and a fetched file changed
+-- before signing that the service refuses.
+--
+-- calog test/testReview.lua --
+
+local BASE = calogArgs()[1]
+local DATA = calogArgs()[2]
+local DVXPKG = calogArgs()[3]
+local ADMIN = calogArgs()[4]
+local CALOG = calogArgs()[5]
+local SINGE = calogArgs()[6]
+local CONF = DATA .. "/review.conf"
+local WORK = DATA .. "/review"
+
+local pass, fail = 0, 0
+local function check(name, got, want)
+ if got == want then pass = pass + 1 else fail = fail + 1
+ calogPrint("FAIL", name, "got", tostring(got), "want", tostring(want)) end
+end
+
+local function call(method, path, body, token, raw)
+ local headers = { ["Content-Type"] = raw and "application/octet-stream" or "application/json" }
+ if token then headers["Authorization"] = "Bearer " .. token end
+ local r = httpRequest({ method = method, url = BASE .. path, headers = headers,
+ body = raw and body or (body and jsonStringify(body) or nil) })
+ local parsed = {}
+ if r.body and r.body ~= "" then
+ local ok, value = pcall(jsonParse, r.body)
+ if ok and type(value) == "table" then parsed = value end
+ end
+ return r.status, parsed, r
+end
+
+local function dpk(name)
+ return fsRead(DATA .. "/" .. name .. ".dpk")
+end
+
+-- The tool: its exit status and what it printed
+local function review(...)
+ local run = procRun({ CALOG, "tools/review.lua", "--", "singe=" .. SINGE, CONF, ... })
+ return run.exit, (run.stdout or "") .. (run.stderr or "")
+end
+
+local function listed(id)
+ local _, cat = call("GET", "/v1/catalogue")
+ for _, entry in ipairs(cat.packages or {}) do
+ if entry.id == id then return entry end
+ end
+ return nil
+end
+
+
+fsWrite(CONF, table.concat({ "server = " .. BASE, "token = " .. ADMIN, "dvxpkg = " .. DVXPKG,
+ "secret = " .. DATA .. "/REPO.SEC", "work = " .. WORK, "" }, "\n"))
+
+check("dice uploaded", (call("POST", "/v1/upload", dpk("dice"), ADMIN, true)), 202)
+
+local exit, said = review("pending")
+check("pending runs", exit, 0)
+check("and lists dice", said:find("acme.dice 1.0.0", 1, true) ~= nil, true)
+
+exit, said = review("fetch", "acme.dice", "1.0.0")
+check("fetch runs", exit, 0)
+check("into the work folder", fsExists(WORK .. "/acme.dice-1.0.0.DPK"), true)
+check("the bytes uploaded", fsRead(WORK .. "/acme.dice-1.0.0.DPK") == dpk("dice"), true)
+check("and shows what it is", said:find("acme.dice", 1, true) ~= nil and said:find("fetched", 1, true) ~= nil, true)
+check("nothing listed before approval", listed("acme.dice"), nil)
+
+exit, said = review("approve", "acme.dice", "1.0.0", "looks fine")
+check("approve runs", exit, 0)
+check("and says so", said:find("approved acme.dice 1.0.0", 1, true) ~= nil, true)
+local entry = listed("acme.dice")
+check("now listed", entry and entry.version, "1.0.0")
+check("signed by the repository", entry and entry.signed, true)
+local _, _, sig = call("GET", "/v1/signature/acme.dice/1.0.0")
+fsWrite(DATA .. "/dice.SIG", sig.body)
+check("DVX would trust it", procRun({ DVXPKG, "verify", DATA .. "/dice.dpk", "-s", DATA .. "/dice.SIG", DATA .. "/REPO.KEY" }).exit, 0)
+
+check("dice 1.1 uploaded", (call("POST", "/v1/upload", dpk("dice2"), ADMIN, true)), 202)
+exit, said = review("reject", "acme.dice", "1.1.0", "not yet")
+check("reject runs", exit, 0)
+local _, versions = call("GET", "/v1/package/acme.dice")
+check("still one version", #(versions.versions or {}), 1)
+exit = review("reject", "acme.dice", "1.1.0")
+check("a rejection needs a note", exit, 2)
+
+exit = review("withdraw", "acme.dice")
+check("withdraw runs", exit, 0)
+check("not listed", listed("acme.dice"), nil)
+exit = review("publish", "acme.dice")
+check("publish runs", exit, 0)
+check("listed again", listed("acme.dice") ~= nil, true)
+
+-- A fetched file that is not what was uploaded: the signature is of other bytes, and refused
+check("dice 1.2 uploaded", (call("POST", "/v1/upload", dpk("dice3"), ADMIN, true)), 202)
+check("fetched", (review("fetch", "acme.dice", "1.2.0")), 0)
+fsWrite(WORK .. "/acme.dice-1.2.0.DPK", dpk("dice2"))
+exit, said = review("approve", "acme.dice", "1.2.0")
+check("a changed file's signature is refused", exit, 1)
+check("as not the repository's", said:find("repository", 1, true) ~= nil, true)
+check("still at 1.0.0", listed("acme.dice").version, "1.0.0")
+
+check("an unknown command", (review("bless", "acme.dice")), 2)
+local bad = DATA .. "/badtoken.conf"
+fsWrite(bad, (fsRead(CONF):gsub("token = %w+", "token = wrong")))
+check("a wrong token", (procRun({ CALOG, "tools/review.lua", "--", "singe=" .. SINGE, bad, "pending" }).exit), 1)
+
+calogPrint(string.format("%d passed, %d failed", pass, fail))
+calogExit(fail == 0 and 0 or 1)
diff --git a/server/tools/review.lua b/server/tools/review.lua
new file mode 100644
index 00000000..216d0ba4
--- /dev/null
+++ b/server/tools/review.lua
@@ -0,0 +1,196 @@
+-- review.lua -- the operator's review of uploaded packages, run on the operator's own computer: the
+-- one place the repository's secret key is kept. It lists what waits, fetches a version and shows
+-- what it is (the package tool's own check of it), approves a version with the repository's
+-- signature made here, or rejects it with a note its uploader reads. The service checks the
+-- signature against the repository's public key; the secret key never leaves this computer.
+--
+-- calog tools/review.lua -- review.conf [arguments] (in the server folder)
+--
+-- It needs singemaster's lib/config.lua (singe=DIR when singemaster is not at ../../singemaster),
+-- and the package tool from a DVX build or the SDK.
+
+local USAGE = [[
+usage: calog tools/review.lua -- [arguments]
+
+ pending versions waiting for a review, oldest first
+ fetch download one into the work folder and show what it is
+ approve [note] sign what was fetched with the repository's key and approve it
+ reject reject one; the note is what the uploader reads
+ withdraw stop listing and handing out a package
+ publish list it again
+
+review.conf:
+ server = https://packages.example.com the service, as anyone reaches it
+ token = ... the service's adminToken, or an administrator's session
+ dvxpkg = /path/to/dvxpkg the package tool
+ secret = /path/to/KPUNCH.SEC the repository's secret key
+ work = review where fetched packages and their signatures are kept
+]]
+
+local REQUIRED = { "server", "token", "dvxpkg", "secret", "work" }
+
+-- calogArgs(): the configuration, the command and its arguments, and singe=DIR if given; without
+-- it, singemaster is beside this repository (from this file: tools, server, the DVX tree)
+local plain = {}
+local SINGE = (calogScript():match("^(.*)[/\\]") or ".") .. "/../../../singemaster"
+for _, value in ipairs(calogArgs()) do
+ local dir = value:match("^singe=(.+)$")
+ if dir then
+ SINGE = dir
+ else
+ plain[#plain + 1] = value
+ end
+end
+
+if #plain < 2 then
+ calogPrint(USAGE)
+ calogExit(2)
+end
+
+local config = load(fsRead(SINGE .. "/lib/config.lua"), "@" .. SINGE .. "/lib/config.lua")()
+local settings, configError = config.load(plain[1], REQUIRED, {})
+if not settings then
+ calogPrint("configuration: " .. tostring(configError))
+ calogExit(1)
+end
+
+local BASE = settings.server:gsub("/+$", "")
+local command = plain[2]
+
+
+-- The service's answer: its status, the JSON it sent (or {}), and the whole reply
+local function call(method, path, body)
+ local reply = httpRequest({ method = method, url = BASE .. path, body = body and jsonStringify(body) or nil,
+ headers = { ["Content-Type"] = "application/json", Authorization = "Bearer " .. settings.token } })
+ local parsed = {}
+
+ if reply.body and reply.body ~= "" then
+ local ok, value = pcall(jsonParse, reply.body)
+ if ok and type(value) == "table" then
+ parsed = value
+ end
+ end
+ return reply.status, parsed, reply
+end
+
+
+-- Stop with what the service said, when it did not say yes
+local function expect(status, parsed, want)
+ if status ~= want then
+ calogPrint(string.format("the service answered %s: %s", tostring(status), tostring(parsed.message or parsed.error or "")))
+ calogExit(1)
+ end
+end
+
+
+-- Where a fetched version, and the signature made of it, are kept
+local function localPath(id, version, extension)
+ return string.format("%s/%s-%s.%s", settings.work, id, version, extension)
+end
+
+
+-- The package tool, its exit status and what it printed
+local function dvxpkg(args)
+ local argv = { settings.dvxpkg }
+
+ for _, value in ipairs(args) do
+ argv[#argv + 1] = value
+ end
+ local run = procRun(argv)
+ return run.exit, (run.stdout or "") .. (run.stderr or "")
+end
+
+
+-- Download a version into the work folder, check its bytes against the digest the service sent, and
+-- show its manifest and files as the package tool reads them. Stops if the tool refuses it.
+local function fetch(id, version)
+ local status, parsed, reply = call("GET", "/v1/admin/download/" .. id .. "/" .. version)
+ expect(status, parsed, 200)
+ if cryptoHashSha256(reply.body) ~= reply.headers["x-content-sha256"] then
+ calogPrint("the download does not match the digest the service sent; try again")
+ calogExit(1)
+ end
+ fsMkdir(settings.work)
+ local path = localPath(id, version, "DPK")
+ fsWrite(path, reply.body)
+
+ local checked, manifest = dvxpkg({ "check", path })
+ if checked ~= 0 then
+ calogPrint("the package tool refuses " .. path .. ":")
+ calogPrint(manifest)
+ calogExit(1)
+ end
+ local _, info = dvxpkg({ "info", path })
+ calogPrint(info)
+ calogPrint(string.format("fetched %s (%d bytes, sha256 %s)", path, #reply.body, reply.headers["x-content-sha256"]))
+ return path
+end
+
+
+local function pending()
+ local status, parsed = call("GET", "/v1/admin/pending")
+ expect(status, parsed, 200)
+ if (parsed.count or 0) == 0 then
+ calogPrint("nothing is waiting")
+ return
+ end
+ for _, row in ipairs(parsed.pending) do
+ calogPrint(string.format("%s %s (%s, %d bytes) from %s, %s", row.packageId, row.version, tostring(row.kind),
+ row.size or 0, tostring(row.uploader or "the operator"), tostring(row.uploadedAt)))
+ calogPrint(string.format(" %s%s", tostring(row.name), row.summary and (": " .. row.summary) or ""))
+ calogPrint(string.format(" publisher's key %s", tostring(row.signerKey or "none")))
+ end
+end
+
+
+-- Approve what was reviewed: the fetched file (fetched now if it was not), signed here
+local function approve(id, version, note)
+ local path = localPath(id, version, "DPK")
+
+ if not fsExists(path) then
+ fetch(id, version)
+ end
+ local signed, said = dvxpkg({ "sign", path, settings.secret, "-d" })
+ if signed ~= 0 then
+ calogPrint("the package tool could not sign it:")
+ calogPrint(said)
+ calogExit(1)
+ end
+ local status, parsed = call("POST", "/v1/admin/version/state",
+ { package = id, version = version, state = "approved", signature = fsRead(localPath(id, version, "SIG")), note = note })
+ expect(status, parsed, 200)
+ calogPrint(string.format("approved %s %s", id, version))
+end
+
+
+local function reject(id, version, note)
+ local status, parsed = call("POST", "/v1/admin/version/state", { package = id, version = version, state = "rejected", note = note })
+ expect(status, parsed, 200)
+ calogPrint(string.format("rejected %s %s", id, version))
+end
+
+
+local function packageState(id, state)
+ local status, parsed = call("POST", "/v1/admin/package/state", { package = id, state = state })
+ expect(status, parsed, 200)
+ calogPrint(string.format("%s is %s", id, state))
+end
+
+
+local COMMANDS = {
+ pending = { 0, function() pending() end },
+ fetch = { 2, function(a) fetch(a[1], a[2]) end },
+ approve = { 2, function(a) approve(a[1], a[2], a[3]) end },
+ reject = { 3, function(a) reject(a[1], a[2], a[3]) end },
+ withdraw = { 1, function(a) packageState(a[1], "withdrawn") end },
+ publish = { 1, function(a) packageState(a[1], "published") end },
+}
+
+local entry = COMMANDS[command]
+local arguments = { table.unpack(plain, 3) }
+if not entry or #arguments < entry[1] then
+ calogPrint(USAGE)
+ calogExit(2)
+end
+entry[2](arguments)
+calogExit(0)
diff --git a/src/apps/kpunch/Makefile b/src/apps/kpunch/Makefile
index 948fce5a..5c67a77a 100644
--- a/src/apps/kpunch/Makefile
+++ b/src/apps/kpunch/Makefile
@@ -49,7 +49,7 @@ BASCOMP = ../../../bin/host/bascomp
# Editor, dvxhelp/helpedit, installs beside the Help Viewer in dvxhelp).
# The program is bin/apps/kpunch//.app, being the last
# part of the directory.
-BASIC_APPS = iconed notepad imgview dvxhelp/helpedit:dvxhelp resedit basdemo widshow clock welcome fileMgr terminal
+BASIC_APPS = iconed notepad imgview dvxhelp/helpedit:dvxhelp resedit basdemo widshow clock welcome fileMgr terminal software pkgbuild
basicDir = $(firstword $(subst :, ,$(1)))
basicName = $(notdir $(call basicDir,$(1)))
diff --git a/src/apps/kpunch/cpanel/cpPrinter.c b/src/apps/kpunch/cpanel/cpPrinter.c
index 82cd9694..c043d42e 100644
--- a/src/apps/kpunch/cpanel/cpPrinter.c
+++ b/src/apps/kpunch/cpanel/cpPrinter.c
@@ -30,6 +30,7 @@
#include "cpPrinter.h"
#include "print/print.h"
+#include "dvxDlg.h"
#include "dvxPlat.h"
#include "box/box.h"
#include "button/button.h"
@@ -56,10 +57,11 @@ typedef struct {
} PrintFnsT;
static PrintFnsT sPrint;
-static AppContextT *sAc = NULL;
-static WidgetT *sModel = NULL;
-static WidgetT *sDest = NULL;
-static WidgetT *sFile = NULL;
+static AppContextT *sAc = NULL;
+static WidgetT *sModel = NULL;
+static WidgetT *sDest = NULL;
+static WidgetT *sFile = NULL;
+static WidgetT *sBrowse = NULL; // beside sFile
static const char *sDestLabels[PrintDestCountE];
static const char *sModelLabels[PrintModelCountE];
@@ -71,6 +73,7 @@ static WidgetT *addRow(WidgetT *parent, const char *label);
void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs);
const char *cpPrinterProblem(void);
void cpPrinterStore(PrefsHandleT *prefs);
+static void onBrowse(WidgetT *w);
static void onDest(WidgetT *w);
static void onPageSetup(WidgetT *w);
@@ -120,8 +123,13 @@ void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
sDest->onChange = onDest;
wgtDropdownSetItems(sDest, sDestLabels, PrintDestCountE);
wgtDropdownSetSelected(sDest, s.dest);
- sFile = wgtTextInput(addRow(page, "File name:"), PRINT_PATH_MAX - 1);
- sFile->prefW = wgtChars(CPR_FIELD_CHARS);
+
+ WidgetT *fileRow = addRow(page, "File name:");
+
+ sFile = wgtTextInput(fileRow, PRINT_PATH_MAX - 1);
+ sFile->prefW = wgtChars(CPR_FIELD_CHARS);
+ sBrowse = wgtButton(fileRow, "&Browse...");
+ sBrowse->onClick = onBrowse;
wgtSetText(sFile, s.filePath);
onDest(sDest);
wgtLabel(page, "Text only works with any printer; choose your printer's");
@@ -154,9 +162,27 @@ void cpPrinterStore(PrefsHandleT *prefs) {
}
+// Browse... beside the file name: the Save dialog, starting in the
+// folder the name is in.
+static void onBrowse(WidgetT *w) {
+ const FileFilterT filters[] = { { PRINT_FILE_FILTER }, { "All Files (*.*)" } };
+ char dir[PRINT_PATH_MAX];
+ char path[PRINT_PATH_MAX];
+
+ (void)w;
+
+ if (platformPathDirName(wgtGetText(sFile), dir, sizeof(dir)) && dvxFileDialog(sAc, PRINT_FILE_TITLE, FD_SAVE, dir, filters, DVX_ARRAY_LEN(filters), path, sizeof(path))) {
+ wgtSetText(sFile, path);
+ }
+}
+
+
// The file name only matters when printing to a file.
static void onDest(WidgetT *w) {
- wgtSetEnabled(sFile, wgtDropdownGetSelected(w) == PrintDestFileE);
+ bool toFile = wgtDropdownGetSelected(w) == PrintDestFileE;
+
+ wgtSetEnabled(sFile, toFile);
+ wgtSetEnabled(sBrowse, toFile);
}
diff --git a/src/apps/kpunch/cpanel/cpSounds.c b/src/apps/kpunch/cpanel/cpSounds.c
index 77037ec0..f3d7aea2 100644
--- a/src/apps/kpunch/cpanel/cpSounds.c
+++ b/src/apps/kpunch/cpanel/cpSounds.c
@@ -26,9 +26,11 @@
// Control Panel still works where it is not installed (the page then says
// so). The sound files offered are those in the default sounds folder,
// the ones DVX.INI names and any the user browses to; "(None)" is always
-// first. The volume applies at once (Cancel puts DVX.INI's back); the
-// sounds, the driver and the MIDI choice are written by OK, and the
-// driver and MIDI choice take effect when DVX next starts.
+// first. The MIDI instrument banks offered are the standard one and
+// those in the banks folders. The volume applies at once (Cancel puts
+// DVX.INI's back); the sounds, the driver, the MIDI choice, and the bank
+// are written by OK, and the driver, MIDI choice, and bank take effect
+// when DVX next starts.
#include "cpSounds.h"
#include "audio/audio.h"
@@ -40,6 +42,7 @@
#include "dropdown/dropdown.h"
#include "label/label.h"
#include "listBox/listBox.h"
+#include "music/music.h"
#include "slider/slider.h"
#include "stb_ds_wrap.h"
@@ -58,6 +61,15 @@
#define CPS_TEXT_BUF 128
#define CPS_DRV_ITEMS (AUDIO_DRIVERS_MAX + 2) // Automatic, the drivers, DVX.INI's own choice
#define PERCENT_MAX 100
+#define CPS_NONE_LABEL "(None)"
+#define CPS_BANK_LABEL "Standard"
+#define CPS_BANK_EXT ".op2"
+
+// A dropdown's items: the values DVX.INI keeps and the labels shown
+typedef struct {
+ char **values; // stb_ds, owned
+ const char **labels; // stb_ds, parallel (into values, or a literal for "")
+} ChoicesT;
// The sound library's functions (NULL when it is not installed)
typedef struct {
@@ -119,8 +131,9 @@ static WidgetT *sVolSldr = NULL;
static WidgetT *sVolLbl = NULL;
static WidgetT *sDrvDrop = NULL;
static WidgetT *sMidiDrop = NULL;
-static char **sFileValues = NULL; // stb_ds: paths as stored ("" for none), owned
-static const char **sFileLabels = NULL; // stb_ds: parallel labels (into sFileValues or literals)
+static WidgetT *sBankDrop = NULL;
+static ChoicesT sFiles; // the sound files as stored ("" for none)
+static ChoicesT sBanks; // the MIDI banks as stored ("" for the standard one)
static char sDrvNames[CPS_DRV_ITEMS][AUDIO_NAME_MAX]; // as stored
static const char *sDrvLabels[CPS_DRV_ITEMS];
static int32_t sDrvCount = 0;
@@ -132,10 +145,13 @@ static int32_t sChoice[SoundCountE];
// Prototypes
// ============================================================
+static int32_t addBanks(PrefsHandleT *prefs);
static int32_t addDriver(const char *name, const char *label);
static int32_t addFile(const char *path);
static WidgetT *addRow(WidgetT *parent, const char *label);
static void addSoundsDir(void);
+static int32_t choiceAdd(ChoicesT *c, const char *value, const char *emptyLabel);
+static void choicesFree(ChoicesT *c);
static int compareNames(const void *a, const void *b);
void cpSoundsApply(void);
void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs);
@@ -151,6 +167,28 @@ static bool resolveLibrary(void);
static void showVolume(void);
+// The MIDI instrument banks: the standard one, those in the banks folders
+// (by the names DVX.INI keeps), and DVX.INI's own choice when it is none
+// of them (so OK keeps it). Returns DVX.INI's choice's item.
+static int32_t addBanks(PrefsHandleT *prefs) {
+ char **names = platformKindReadDir(DvxKindBanksE);
+ int32_t sel;
+
+ choiceAdd(&sBanks, "", CPS_BANK_LABEL);
+ qsort(names, arrlen(names), sizeof(names[0]), compareNames);
+
+ for (int32_t i = 0; i < (int32_t)arrlen(names); i++) {
+ if (dvxHasExt(names[i], CPS_BANK_EXT)) {
+ choiceAdd(&sBanks, names[i], CPS_BANK_LABEL);
+ }
+ }
+
+ dvxReadDirFree(names);
+ sel = choiceAdd(&sBanks, prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, ""), CPS_BANK_LABEL);
+ return sel;
+}
+
+
// A driver item: the name stored and, when label is NULL, shown.
static int32_t addDriver(const char *name, const char *label) {
snprintf(sDrvNames[sDrvCount], AUDIO_NAME_MAX, "%s", name);
@@ -171,23 +209,7 @@ static int32_t addFile(const char *path) {
platformPathNative(native);
}
- for (int32_t i = 0; i < (int32_t)arrlen(sFileValues); i++) {
- if (strcasecmp(sFileValues[i], native) == 0) {
- return i;
- }
- }
-
- char *value = strdup(native);
-
- if (!value) {
- return 0;
- }
-
- const char *label = value[0] ? value : "(None)";
-
- arrput(sFileValues, value);
- arrput(sFileLabels, label);
- return (int32_t)arrlen(sFileValues) - 1;
+ return choiceAdd(&sFiles, native, CPS_NONE_LABEL);
}
@@ -220,6 +242,42 @@ static void addSoundsDir(void) {
}
+// The item of value, added when it is not there yet (told apart ignoring
+// case: DOS names have none); "" shows as emptyLabel. Out of memory: the
+// first item.
+static int32_t choiceAdd(ChoicesT *c, const char *value, const char *emptyLabel) {
+ char *copy;
+
+ for (int32_t i = 0; i < (int32_t)arrlen(c->values); i++) {
+ if (strcasecmp(c->values[i], value) == 0) {
+ return i;
+ }
+ }
+
+ copy = strdup(value);
+
+ if (!copy) {
+ return 0;
+ }
+
+ arrput(c->values, copy);
+ arrput(c->labels, copy[0] ? copy : emptyLabel);
+ return (int32_t)arrlen(c->values) - 1;
+}
+
+
+static void choicesFree(ChoicesT *c) {
+ for (int32_t i = 0; i < (int32_t)arrlen(c->values); i++) {
+ free(c->values[i]);
+ }
+
+ arrfree(c->values);
+ arrfree(c->labels);
+ c->values = NULL;
+ c->labels = NULL;
+}
+
+
static int compareNames(const void *a, const void *b) {
return strcasecmp(*(const char * const *)a, *(const char * const *)b);
}
@@ -260,7 +318,7 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
sFileDrop = wgtDropdown(row);
sFileDrop->weight = WGT_WEIGHT_FILL;
sFileDrop->onChange = onFile;
- wgtDropdownSetItems(sFileDrop, sFileLabels, (int32_t)arrlen(sFileLabels));
+ wgtDropdownSetItems(sFileDrop, sFiles.labels, (int32_t)arrlen(sFiles.labels));
WidgetT *btn = wgtButton(row, "Browse...");
@@ -310,13 +368,19 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
wgtDropdownSetItems(sMidiDrop, sMidiLabels, AUDIO_MIDI_CHOICE_COUNT);
wgtDropdownSetSelected(sMidiDrop, sSnd.midi(prefs));
+ int32_t bankSel = addBanks(prefs);
+
+ sBankDrop = wgtDropdown(addRow(page, "Instruments:"));
+ wgtDropdownSetItems(sBankDrop, sBanks.labels, (int32_t)arrlen(sBanks.labels));
+ wgtDropdownSetSelected(sBankDrop, bankSel);
+
AudioInfoT info;
char text[CPS_TEXT_BUF];
sSnd.getInfo(&info);
snprintf(text, sizeof(text), "Sound device: %s", info.running ? info.description : "none running");
wgtLabel(page, text);
- wgtLabel(page, "A new driver or MIDI choice takes effect when DVX next starts.");
+ wgtLabel(page, "Sound driver and MIDI changes take effect when DVX next starts.");
wgtListBoxSetSelected(sEventList, 0);
onEvent(sEventList);
@@ -324,14 +388,8 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
void cpSoundsFree(void) {
- for (int32_t i = 0; i < (int32_t)arrlen(sFileValues); i++) {
- free(sFileValues[i]);
- }
-
- arrfree(sFileValues);
- arrfree(sFileLabels);
- sFileValues = NULL;
- sFileLabels = NULL;
+ choicesFree(&sFiles);
+ choicesFree(&sBanks);
}
@@ -348,7 +406,16 @@ void cpSoundsStore(PrefsHandleT *prefs) {
}
for (int32_t i = 0; i < SoundCountE; i++) {
- sSnd.setFile(prefs, (DvxSoundE)i, sFileValues[sChoice[i]]);
+ sSnd.setFile(prefs, (DvxSoundE)i, sFiles.values[sChoice[i]]);
+ }
+
+ // The standard bank is no setting at all
+ const char *bank = sBanks.values[wgtDropdownGetSelected(sBankDrop)];
+
+ if (bank[0]) {
+ prefsSetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, bank);
+ } else {
+ prefsRemove(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK);
}
sSnd.store(prefs, sDrvNames[wgtDropdownGetSelected(sDrvDrop)], wgtDropdownGetSelected(sMidiDrop), wgtSliderGetValue(sVolSldr));
@@ -374,7 +441,7 @@ static void onBrowse(WidgetT *w) {
int32_t item = addFile(path);
- wgtDropdownSetItems(sFileDrop, sFileLabels, (int32_t)arrlen(sFileLabels));
+ wgtDropdownSetItems(sFileDrop, sFiles.labels, (int32_t)arrlen(sFiles.labels));
wgtDropdownSetSelected(sFileDrop, item);
onFile(sFileDrop);
}
@@ -412,7 +479,7 @@ static void onTest(WidgetT *w) {
return;
}
- int32_t rc = sSnd.playFile(platformKindFind(path, sizeof(path), DvxKindSoundsE, sFileValues[item]) ? path : sFileValues[item]);
+ int32_t rc = sSnd.playFile(platformKindFind(path, sizeof(path), DvxKindSoundsE, sFiles.values[item]) ? path : sFiles.values[item]);
if (rc < 0) {
dvxMessageBox(sAc, "Sounds", sSnd.errorText(rc), MB_OK | MB_ICONWARNING);
diff --git a/src/apps/kpunch/cpanel/cpanel.c b/src/apps/kpunch/cpanel/cpanel.c
index 862efa34..4bbbc7f6 100644
--- a/src/apps/kpunch/cpanel/cpanel.c
+++ b/src/apps/kpunch/cpanel/cpanel.c
@@ -31,7 +31,7 @@
// Network -- TCP/IP settings and the network's state (cpNet.c)
// Printer -- printer model, port and page setup (cpPrinter.c)
// Game Controllers -- joysticks: test view, calibration, keyboard joystick (cpJoystick.c)
-// Sounds -- the sound of each desktop event, volume, sound driver, MIDI music (cpSounds.c)
+// Sounds -- the sound of each desktop event, volume, sound driver, MIDI music and instruments (cpSounds.c)
//
// Changes preview live. OK saves to DVX.INI, Cancel reverts to the
// state captured when the control panel was opened (an in-memory INI
diff --git a/src/apps/kpunch/cpanel/cpanel.dhs b/src/apps/kpunch/cpanel/cpanel.dhs
index 9a42a8ba..3b48cd80 100644
--- a/src/apps/kpunch/cpanel/cpanel.dhs
+++ b/src/apps/kpunch/cpanel/cpanel.dhs
@@ -284,7 +284,7 @@ The Printer tab tells DVX about your printer. Every program prints to the printe
------- ------------
Printer model Text only works with any printer, but prints only text. Choose Epson 9-pin, Epson 24-pin, or HP LaserJet if your printer is one of those or works like one (most dot matrix printers work like an Epson, most laser printers like a LaserJet); then pictures can be printed too.
Connected to The printer port the printer is plugged into: LPT1, LPT2, or LPT3. Choose File to print into a file instead.
- File name The file printouts go into when Connected to is File. Each printout replaces it.
+ File name The file printouts go into when Connected to is File: type it, or click Browse... to pick it. Each printout replaces it.
.endtable
Page Setup... opens the Page Setup dialog: the margins, lines per page, header, and footer used when text is printed. Its changes are saved as soon as you choose OK in that dialog.
@@ -362,8 +362,11 @@ The Sounds tab chooses the sound DVX makes for each event, such as starting, end
Volume How loud all sounds and music are, in percent. It changes at once.
Sound driver The sound hardware to use. Automatic finds it by itself (the usual choice); sb is a Sound Blaster, speaker the PC speaker, null no sound at all.
MIDI music Where MIDI music plays: Automatic (the music chip on the sound card, or DVX's own synthesizer that sounds much the same), a MIDI port with an external synthesizer, the FM synthesizer chip, or the built-in synthesizer.
+ Instruments The instrument sounds MIDI music uses on the music chip and the built-in synthesizer: Standard (the set that comes with DVX), or another instrument bank installed with an add-on package or put in the BANKS folder of your user folder.
.endtable
+A new sound driver, MIDI music choice, or set of instruments takes effect when DVX next starts.
+
.h2 The Events
.table
diff --git a/src/apps/kpunch/dvxbasic/Makefile b/src/apps/kpunch/dvxbasic/Makefile
index c8c25c1b..2df422e5 100644
--- a/src/apps/kpunch/dvxbasic/Makefile
+++ b/src/apps/kpunch/dvxbasic/Makefile
@@ -86,8 +86,11 @@ DEV_OBJS = $(COMP_OBJS) $(patsubst basdev/%.c,$(OBJDIR)/%.o,$(DEV_SRCS))
DEV_TARGETDIR = $(LIBSDIR)/kpunch/basdev
DEV_TARGET = $(DEV_TARGETDIR)/basdev.lib
-# IDE app objects
-IDE_OBJS = $(OBJDIR)/ideMain.o $(OBJDIR)/ideDesigner.o $(OBJDIR)/ideMenuEditor.o $(OBJDIR)/ideProject.o $(OBJDIR)/ideToolbox.o $(OBJDIR)/ideProperties.o
+# IDE app objects (Make Package calls the package library, LIBS\KPUNCH\PKG)
+IDE_OBJS = $(OBJDIR)/ideMain.o $(OBJDIR)/ideDesigner.o $(OBJDIR)/ideMenuEditor.o $(OBJDIR)/ideProject.o $(OBJDIR)/ideToolbox.o $(OBJDIR)/ideProperties.o $(OBJDIR)/idePackage.o
+PKG_INCLUDE = -I../../../libs/kpunch/pkg
+
+$(OBJDIR)/idePackage.o: CFLAGS += $(PKG_INCLUDE)
APP_TARGET = $(APPDIR)/dvxbasic.app
# Standalone stub (embedded as IDE resource)
diff --git a/src/apps/kpunch/dvxbasic/basRes.h b/src/apps/kpunch/dvxbasic/basRes.h
index 03b7bcac..ad536c91 100644
--- a/src/apps/kpunch/dvxbasic/basRes.h
+++ b/src/apps/kpunch/dvxbasic/basRes.h
@@ -77,6 +77,13 @@ const char *dvxSkipWs(const char *s);
#define BAS_INI_SECTION_FORMS "Forms"
#define BAS_INI_SECTION_FONTS "Fonts" // .DFN fonts the program carries (font resources)
+// [Package] of a .dbp file: what File > Make Package makes the program
+// (or control) into
+#define BAS_INI_SECTION_PACKAGE "Package"
+#define BAS_INI_KEY_PKG_ID "Id" // vendor.name
+#define BAS_INI_KEY_PKG_NEEDS "Needs" // "id version, id version": the packages it needs
+#define BAS_INI_KEY_PKG_KEY "Key" // the secret key file it was last signed with ("": not signed)
+
// A list section's keys: File0, File1, ... (readers stop at the first
// one missing), and room for one with its NUL
#define BAS_INI_KEY_FILE_FMT "File%" PRId32
diff --git a/src/apps/kpunch/dvxbasic/basdev/basDevBas.c b/src/apps/kpunch/dvxbasic/basdev/basDevBas.c
index e0df1ed7..b72c2d5b 100644
--- a/src/apps/kpunch/dvxbasic/basdev/basDevBas.c
+++ b/src/apps/kpunch/dvxbasic/basdev/basDevBas.c
@@ -63,6 +63,7 @@
#include "dvxTypes.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
@@ -73,11 +74,6 @@
DVX_ABI_STAMP(basdev);
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
-#define ROW_CELL_SEP '\t' // between the cells of a row (ListView SetRows)
-#define ROW_END '\n' // after each row
#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
#define DEV_OUTPUT_MAX 32768 // PRINT text kept for RunOutput$ (oldest lines dropped)
#define DEV_MESSAGE_LEN (BAS_DBG_VALUE_LEN * 2)
@@ -89,7 +85,6 @@ DVX_ABI_STAMP(basdev);
#define DEV_KIND_AUTO (-1) // CompSourceAdd: the kind follows the file name
#define DEV_NO_INDEX (-1) // no such keyword / file
#define DEV_TOKEN_ROW_MAX (BAS_MAX_TOKEN_LEN + DEV_MESSAGE_LEN) // one CompTokens$ row
-#define DEV_TEXT_BLANK ' ' // what a TAB or line break inside a cell becomes
#define DEV_DESIGN_BUSY "The design UserControls' code is running; try again when it is done."
// The states RunState reports (debug.bas RUN_*)
@@ -183,7 +178,7 @@ typedef void (*DevCleanupRegFnT)(DevCleanupFnT fn);
static DevHandleT *sHandles = NULL; // stb_ds; handle = index + 1
static bool sCleanupHooked = false;
-static char *sText = NULL; // stb_ds: the last string result
+static BasTextT sText; // the last string result
static char *sCompError = NULL; // stb_ds: why the last Comp* call failed
static int32_t sCompErrorLine = 0; // concatenated line of the last compile error
static int32_t sCompErrorCol = 0;
@@ -305,9 +300,6 @@ int32_t RunState(int32_t run);
void RunStop(int32_t run);
static int32_t stateOf(const DevRunT *run);
static void sweepDeferred(void);
-static void textAppend(const char *s, int32_t len);
-static const char *textOf(const char *s);
-static void textReset(void);
static void tokenRow(void *ctx, BasSrcKindE kind, int32_t line, int32_t col, int32_t pos, int32_t len);
static void withEventsRow(void *ctx, const char *name, const char *className);
@@ -333,9 +325,9 @@ static void appEnded(int32_t appId) {
// becomes a blank so the row keeps its shape.
static void cellAppend(const char *text, int32_t len) {
for (int32_t i = 0; i < len; i++) {
- char c = (text[i] == ROW_CELL_SEP || text[i] == ROW_END || text[i] == '\r') ? DEV_TEXT_BLANK : text[i];
+ char c = (text[i] == BAS_ROW_CELL_SEP || text[i] == BAS_ROW_END || text[i] == '\r') ? BAS_ROW_BLANK : text[i];
- textAppend(&c, 1);
+ basTextAppendLen(&sText, &c, 1);
}
}
@@ -412,9 +404,9 @@ int32_t CompBuildApp(int32_t mdl, int32_t src, const char *outPath, int32_t rele
// One row per Event a class module declares: name, parameter list.
const char *CompClassEvents(const char *classText) {
- textReset();
+ basTextStart(&sText);
basSourceClassEvents(classText ? classText : "", classEventRow, NULL);
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -614,7 +606,7 @@ const char *CompHelpCtrlTopic(const char *typeName) {
char topic[BAS_MAX_IDENT + sizeof(BAS_HELP_CTRL_PREFIX)];
basHelpCtrlTopic(typeName ? typeName : "", topic, (int32_t)sizeof(topic));
- return textOf(topic);
+ return basTextSet(&sText, topic);
}
@@ -665,7 +657,7 @@ const char *CompMapFile(int32_t src, int32_t line) {
const char *path = NULL;
int32_t fileLine = 0;
- return (s && basBuildMapLine(s->ps.map, line, &path, &fileLine)) ? textOf(path) : "";
+ return (s && basBuildMapLine(s->ps.map, line, &path, &fileLine)) ? basTextSet(&sText, path) : "";
}
@@ -691,7 +683,7 @@ const char *CompProcList(const char *source) {
const char *pos = source ? source : "";
int32_t line = 1;
- textReset();
+ basTextStart(&sText);
while (*pos) {
bool isSub = false;
@@ -706,9 +698,9 @@ const char *CompProcList(const char *source) {
basProcNameFromDecl(afterKw, name, sizeof(name));
- int32_t n = snprintf(row, sizeof(row), "%s%c%s%c%" PRId32 "%c%" PRId32 "%c", isSub ? DEV_KIND_SUB : DEV_KIND_FUNCTION, ROW_CELL_SEP, name, ROW_CELL_SEP, line, ROW_CELL_SEP, line + endOffset, ROW_END);
+ int32_t n = snprintf(row, sizeof(row), "%s%c%s%c%" PRId32 "%c%" PRId32 "%c", isSub ? DEV_KIND_SUB : DEV_KIND_FUNCTION, BAS_ROW_CELL_SEP, name, BAS_ROW_CELL_SEP, line, BAS_ROW_CELL_SEP, line + endOffset, BAS_ROW_END);
- textAppend(row, n);
+ basTextAppendLen(&sText, row, n);
line += endOffset + 1;
pos = end;
continue;
@@ -718,7 +710,7 @@ const char *CompProcList(const char *source) {
line++;
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -726,17 +718,17 @@ const char *CompProcList(const char *source) {
const char *CompProcs(int32_t mdl) {
DevModuleT *m = (DevModuleT *)handleOf(mdl, DevModuleE);
- textReset();
+ basTextStart(&sText);
for (int32_t i = 0; m && i < m->mod->procCount; i++) {
const BasProcEntryT *p = &m->mod->procs[i];
char row[DEV_MESSAGE_LEN];
- int32_t n = snprintf(row, sizeof(row), "%s%c%s%c%s%c", p->name, ROW_CELL_SEP, p->isFunction ? DEV_KIND_FUNCTION : DEV_KIND_SUB, ROW_CELL_SEP, p->formName, ROW_END);
+ int32_t n = snprintf(row, sizeof(row), "%s%c%s%c%s%c", p->name, BAS_ROW_CELL_SEP, p->isFunction ? DEV_KIND_FUNCTION : DEV_KIND_SUB, BAS_ROW_CELL_SEP, p->formName, BAS_ROW_END);
- textAppend(row, n);
+ basTextAppendLen(&sText, row, n);
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -747,10 +739,10 @@ const char *CompRenameInCode(const char *source, const char *oldName, const char
char *renamed = basSourceRename(src, oldName, newName, codeOnly != 0);
if (!renamed) {
- return textOf(src);
+ return basTextSet(&sText, src);
}
- const char *text = textOf(renamed);
+ const char *text = basTextSet(&sText, renamed);
free(renamed);
return text;
@@ -801,17 +793,17 @@ int32_t CompSourceCreate(void) {
const char *CompSourceFiles(int32_t src) {
DevSourceT *s = (DevSourceT *)handleOf(src, DevSourceE);
- textReset();
+ basTextStart(&sText);
for (int32_t i = 0; s && i < (int32_t)arrlen(s->ps.map); i++) {
const BasSourceMapT *m = &s->ps.map[i];
char row[DVX_MAX_PATH + DEV_MESSAGE_LEN];
- int32_t n = snprintf(row, sizeof(row), "%s%c%" PRId32 "%c%" PRId32 "%c", m->path, ROW_CELL_SEP, m->firstFileLine, ROW_CELL_SEP, m->startLine, ROW_END);
+ int32_t n = snprintf(row, sizeof(row), "%s%c%" PRId32 "%c%" PRId32 "%c", m->path, BAS_ROW_CELL_SEP, m->firstFileLine, BAS_ROW_CELL_SEP, m->startLine, BAS_ROW_END);
- textAppend(row, n);
+ basTextAppendLen(&sText, row, n);
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -863,17 +855,17 @@ const char *CompTokens(const char *source) {
DevTokensT tokens;
tokens.src = source ? source : "";
- textReset();
+ basTextStart(&sText);
basSourceTokens(tokens.src, (int32_t)strlen(tokens.src), tokenRow, &tokens);
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
// One row per WithEvents variable of source: name, class.
const char *CompWithEvents(const char *source) {
- textReset();
+ basTextStart(&sText);
basSourceWithEvents(source ? source : "", withEventsRow, NULL);
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -887,13 +879,13 @@ static int32_t currentAppId(void) {
const char *DbgCallStack(int32_t run) {
DevRunT *r = runPaused(run);
- textReset();
+ basTextStart(&sText);
if (r) {
basDbgCallStack(r->rt->vm, r->pausedLine, rowAdd, NULL);
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -915,7 +907,7 @@ const char *DbgEval(int32_t run, const char *expr) {
return "";
}
- return textOf(basDbgEval(r->rt->vm, expr, out, sizeof(out)) ? out : BAS_DBG_EVAL_ERROR);
+ return basTextSet(&sText, basDbgEval(r->rt->vm, expr, out, sizeof(out)) ? out : BAS_DBG_EVAL_ERROR);
}
@@ -929,7 +921,7 @@ const char *DbgExec(int32_t run, const char *line) {
cap.len = 0;
out[0] = '\0';
basDbgExec(r ? r->rt->vm : NULL, line ? line : "", collectPrint, &cap);
- return textOf(out);
+ return basTextSet(&sText, out);
}
@@ -943,13 +935,13 @@ int32_t DbgLine(int32_t run) {
const char *DbgLocals(int32_t run) {
DevRunT *r = runPaused(run);
- textReset();
+ basTextStart(&sText);
if (r) {
basDbgLocals(r->rt->vm, rowAdd, NULL);
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -1358,7 +1350,7 @@ const char *ProjCtlHeader(const char *text, const char *key) {
char value[BAS_UC_TEXT_MAX] = "";
prjCtlHeaderGet(text, key, value, (int32_t)sizeof(value));
- return textOf(value);
+ return basTextSet(&sText, value);
}
@@ -1374,7 +1366,7 @@ const char *ProjCtlKeys(void) {
};
const PrjCtlKeyT *k;
- textReset();
+ basTextStart(&sText);
for (int32_t i = 0; (k = prjCtlKeyAt(i)) != NULL; i++) {
const char *cells[] = { k->key, sKinds[k->kind] };
@@ -1382,7 +1374,7 @@ const char *ProjCtlKeys(void) {
rowAdd(NULL, cells, DVX_ARRAY_LEN(cells));
}
- return sText ? sText : "";
+ return basTextEnd(&sText);
}
@@ -1393,12 +1385,12 @@ const char *ProjCtlNew(const char *name) {
if (!text) {
compFail("A UserControl's name must be an identifier.");
- return textOf("");
+ return basTextSet(&sText, "");
}
compFail("");
- const char *out = textOf(text);
+ const char *out = basTextSet(&sText, text);
free(text);
return out;
@@ -1412,12 +1404,12 @@ const char *ProjCtlSetHeader(const char *text, const char *key, const char *valu
if (!changed) {
compFail("Not a UserControl header key, or a value it does not take.");
- return textOf("");
+ return basTextSet(&sText, "");
}
compFail("");
- const char *out = textOf(changed);
+ const char *out = basTextSet(&sText, changed);
free(changed);
return out;
@@ -1463,7 +1455,7 @@ const char *ProjFilePath(int32_t prj, int32_t idx) {
prjFullPath(p, idx, path, (int32_t)sizeof(path));
}
- return textOf(path);
+ return basTextSet(&sText, path);
}
@@ -1493,7 +1485,7 @@ const char *ProjGet(int32_t prj, const char *key) {
prjFieldGet(p, key, value, (int32_t)sizeof(value));
}
- return textOf(value);
+ return basTextSet(&sText, value);
}
@@ -1629,18 +1621,7 @@ int32_t ProjSet(int32_t prj, const char *key, const char *value) {
static void rowAdd(void *ctx, const char *const *cells, int32_t cellCount) {
(void)ctx;
- char sep = ROW_CELL_SEP;
- char end = ROW_END;
-
- for (int32_t c = 0; c < cellCount; c++) {
- if (c > 0) {
- textAppend(&sep, 1);
- }
-
- textAppend(cells[c], (int32_t)strlen(cells[c]));
- }
-
- textAppend(&end, 1);
+ basTextRow(&sText, cells, cellCount);
}
@@ -1820,7 +1801,7 @@ const char *RunOutput(int32_t run) {
return "";
}
- const char *text = textOf(r->output);
+ const char *text = basTextSet(&sText, r->output);
r->output[0] = '\0';
r->outputLen = 0;
@@ -2026,42 +2007,16 @@ static void sweepDeferred(void) {
}
-static void textAppend(const char *s, int32_t len) {
- if (sText && arrlen(sText) > 0) {
- arrsetlen(sText, arrlen(sText) - 1); // drop the NUL
- }
-
- for (int32_t i = 0; i < len; i++) {
- arrput(sText, s[i]);
- }
-
- arrput(sText, '\0');
-}
-
-
-static const char *textOf(const char *s) {
- textReset();
- textAppend(s, (int32_t)strlen(s));
- return sText ? sText : "";
-}
-
-
-static void textReset(void) {
- arrsetlen(sText, 0);
- arrput(sText, '\0');
-}
-
-
// cppcheck-suppress constParameterCallback ; a token callback: the context stays a plain void * for callers that keep state
static void tokenRow(void *ctx, BasSrcKindE kind, int32_t line, int32_t col, int32_t pos, int32_t len) {
const DevTokensT *tokens = (const DevTokensT *)ctx;
char row[DEV_TOKEN_ROW_MAX];
- int32_t n = snprintf(row, sizeof(row), "%" PRId32 "%c%" PRId32 "%c%" PRId32 "%c%s%c", line, ROW_CELL_SEP, col, ROW_CELL_SEP, len, ROW_CELL_SEP, basSourceKindName(kind), ROW_CELL_SEP);
- char end = ROW_END;
+ int32_t n = snprintf(row, sizeof(row), "%" PRId32 "%c%" PRId32 "%c%" PRId32 "%c%s%c", line, BAS_ROW_CELL_SEP, col, BAS_ROW_CELL_SEP, len, BAS_ROW_CELL_SEP, basSourceKindName(kind), BAS_ROW_CELL_SEP);
+ char end = BAS_ROW_END;
- textAppend(row, n);
+ basTextAppendLen(&sText, row, n);
cellAppend(tokens->src + pos, len);
- textAppend(&end, 1);
+ basTextAppendLen(&sText, &end, 1);
}
diff --git a/src/apps/kpunch/dvxbasic/basdev/basProject.c b/src/apps/kpunch/dvxbasic/basdev/basProject.c
index ba4d1c99..8d4d6336 100644
--- a/src/apps/kpunch/dvxbasic/basdev/basProject.c
+++ b/src/apps/kpunch/dvxbasic/basdev/basProject.c
@@ -127,7 +127,10 @@ static const PrjFieldT sFields[] = {
PRJ_TEXT(BAS_INI_SECTION_PROJECT, BAS_INI_KEY_COMMANDARGS, commandArgs, false),
PRJ_TEXT(BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, startupForm, true),
PRJ_FLAG(BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, optionExplicit, true),
- PRJ_FLAG(BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_MULTIINSTANCE, multiInstance, false)
+ PRJ_FLAG(BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_MULTIINSTANCE, multiInstance, false),
+ PRJ_TEXT(BAS_INI_SECTION_PACKAGE, BAS_INI_KEY_PKG_ID, pkgId, false),
+ PRJ_TEXT(BAS_INI_SECTION_PACKAGE, BAS_INI_KEY_PKG_NEEDS, pkgNeeds, false),
+ PRJ_PATH(BAS_INI_SECTION_PACKAGE, BAS_INI_KEY_PKG_KEY, pkgKey)
};
// Per file kind: the .dbp section listing its files, their extension
@@ -1082,6 +1085,7 @@ bool prjSave(const PrjStateT *prj) {
}
prjSaveFields(h, prj, BAS_INI_SECTION_SETTINGS);
+ prjSaveFields(h, prj, BAS_INI_SECTION_PACKAGE);
bool ok = prefsSaveAs(h, prj->projectPath);
prefsClose(h);
diff --git a/src/apps/kpunch/dvxbasic/basdev/basProject.h b/src/apps/kpunch/dvxbasic/basdev/basProject.h
index 84ef9963..386e9948 100644
--- a/src/apps/kpunch/dvxbasic/basdev/basProject.h
+++ b/src/apps/kpunch/dvxbasic/basdev/basProject.h
@@ -125,6 +125,10 @@ typedef struct {
char iconPath[DVX_MAX_PATH]; // relative path to icon BMP
char helpFile[DVX_MAX_PATH]; // relative path to .hlp file
char commandArgs[PRJ_MAX_ARGS]; // COMMAND$ for runs from the IDE (not built into the program)
+ // [Package]: File > Make Package
+ char pkgId[PRJ_MAX_NAME]; // the package name, vendor.name ("" until given)
+ char pkgNeeds[PRJ_MAX_DESC]; // "id version, id version": the packages it needs
+ char pkgKey[DVX_MAX_PATH]; // the secret key file last signed with ("": not signed)
bool optionExplicit; // require DIM before use
bool multiInstance; // the built program may run more than once at a time
bool dirty;
diff --git a/src/apps/kpunch/dvxbasic/compiler/fold.c b/src/apps/kpunch/dvxbasic/compiler/fold.c
index 564d3e5f..9d10f7a0 100644
--- a/src/apps/kpunch/dvxbasic/compiler/fold.c
+++ b/src/apps/kpunch/dvxbasic/compiler/fold.c
@@ -96,7 +96,7 @@ bool basConstConvert(BasCompilerT *c, BasConstValT *v, uint8_t asType, const cha
} else if (asType == BAS_TYPE_SINGLE) {
v->dblVal = (float)v->dblVal;
} else if (asType == BAS_TYPE_BOOLEAN) {
- v->intVal = (v->dblVal != 0.0) ? BAS_TRUE_INT : 0;
+ v->intVal = (v->dblVal != 0.0) ? BAS_TRUE : 0;
v->dblVal = v->intVal;
}
@@ -535,7 +535,7 @@ static BasNodeT *valueNode(BasCompilerT *c, BasValueT v, const BasNodeT *at) {
BasLitT *lit = (BasLitT *)basNodeNew(&c->unitArena, (int32_t)sizeof(BasLitT), NK_BOOL, at->line, at->col);
if (lit != NULL) {
- lit->ival = v.boolVal ? BAS_TRUE_INT : 0;
+ lit->ival = v.boolVal ? BAS_TRUE : 0;
lit->h.type = BAS_TYPE_BOOLEAN;
}
diff --git a/src/apps/kpunch/dvxbasic/compiler/front.h b/src/apps/kpunch/dvxbasic/compiler/front.h
index 2cfaa695..37adccfd 100644
--- a/src/apps/kpunch/dvxbasic/compiler/front.h
+++ b/src/apps/kpunch/dvxbasic/compiler/front.h
@@ -41,6 +41,7 @@
#include "codegen.h"
#include "opcodes.h"
#include "../runtime/vm.h"
+#include "dvxBas.h"
#include
#include
@@ -71,9 +72,6 @@
// value.
#define BAS_CLASS_HOST (-3)
-// BASIC's TRUE as an integer.
-#define BAS_TRUE_INT (-1)
-
// DEF FN function names start with FN.
#define BAS_DEFFN_PREFIX "FN"
diff --git a/src/apps/kpunch/dvxbasic/compiler/parseExpr.c b/src/apps/kpunch/dvxbasic/compiler/parseExpr.c
index 99fc565a..0869733e 100644
--- a/src/apps/kpunch/dvxbasic/compiler/parseExpr.c
+++ b/src/apps/kpunch/dvxbasic/compiler/parseExpr.c
@@ -1145,7 +1145,7 @@ static BasNodeT *parsePrimary(BasCompilerT *c) {
BasLitT *lit = (BasLitT *)basParseNode(c, (int32_t)sizeof(BasLitT), NK_BOOL);
if (lit != NULL) {
- lit->ival = (tt == TOK_TRUE_KW) ? BAS_TRUE_INT : 0;
+ lit->ival = (tt == TOK_TRUE_KW) ? BAS_TRUE : 0;
lit->h.type = BAS_TYPE_BOOLEAN;
}
diff --git a/src/apps/kpunch/dvxbasic/compiler/resolveExpr.c b/src/apps/kpunch/dvxbasic/compiler/resolveExpr.c
index fd291f51..ef7e9a91 100644
--- a/src/apps/kpunch/dvxbasic/compiler/resolveExpr.c
+++ b/src/apps/kpunch/dvxbasic/compiler/resolveExpr.c
@@ -935,7 +935,7 @@ BasNodeT *basNodeConst(BasCompilerT *c, const BasConstValT *v, const BasNodeT *a
BasLitT *lit = (BasLitT *)basNodeNew(&c->unitArena, (int32_t)sizeof(BasLitT), NK_BOOL, at->line, at->col);
if (lit != NULL) {
- lit->ival = v->intVal ? BAS_TRUE_INT : 0;
+ lit->ival = v->intVal ? BAS_TRUE : 0;
lit->h.type = BAS_TYPE_BOOLEAN;
}
diff --git a/src/apps/kpunch/dvxbasic/formrt/formDesign.c b/src/apps/kpunch/dvxbasic/formrt/formDesign.c
index 7cebaabf..fda082f3 100644
--- a/src/apps/kpunch/dvxbasic/formrt/formDesign.c
+++ b/src/apps/kpunch/dvxbasic/formrt/formDesign.c
@@ -57,15 +57,13 @@
#include "dvxVideo.h"
#include "dvxWgtP.h"
#include "thirdparty/stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-
// Selection handles: gray levels of a resize handle (black) and of a
// decorative one, and the handle positions (0, 1/2 or 2/2 of the control
// size across and down, clockwise from the top-left corner).
diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.c b/src/apps/kpunch/dvxbasic/formrt/formrt.c
index ada96320..b05c46ab 100644
--- a/src/apps/kpunch/dvxbasic/formrt/formrt.c
+++ b/src/apps/kpunch/dvxbasic/formrt/formrt.c
@@ -28,6 +28,7 @@
#include "formrt.h"
#include "../compiler/basEvents.h"
+#include "../compiler/lexer.h"
#include "../compiler/opcodes.h"
#include "../runtime/basErrors.h"
#include "../runtime/gfx.h"
@@ -896,6 +897,7 @@ const char *DlgFileOpen(const char *title, const char *filter);
const char *DlgFileOpenIn(const char *title, const char *filter, const char *folder);
const char *DlgFileSave(const char *title, const char *filter);
const char *DlgFileSaveIn(const char *title, const char *filter, const char *folder);
+const char *DlgFolder(const char *title, const char *folder);
int32_t DlgFont(const char *title, const char *family, int32_t size, int32_t style, int32_t flags);
const char *DlgFontName(void);
int32_t DlgFontSize(void);
@@ -1475,7 +1477,17 @@ void basFormRtBindVm(BasFormRtT *rt) {
BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc) {
- BasFormRtT *rt = (BasFormRtT *)ctx;
+ BasFormRtT *rt = (BasFormRtT *)ctx;
+ size_t len = strlen(methodName);
+ char bare[BAS_MAX_TOKEN_LEN];
+
+ // A method that gives a string may be named with BASIC's $ (GetCell$),
+ // as a DECLARE LIBRARY function may: the controls name it without
+ if (len > 1 && methodName[len - 1] == '$' && len <= sizeof(bare)) {
+ memcpy(bare, methodName, len - 1);
+ bare[len - 1] = '\0';
+ methodName = bare;
+ }
if (ctrlRef == &sClipboardObj) {
return clipboardMethod(rt, methodName, args, argc);
@@ -4939,6 +4951,14 @@ const char *DlgFileSaveIn(const char *title, const char *filter, const char *fol
}
+// The folder dialog starting at folder ("" = the current folder; one not
+// made yet opens at the nearest one that is): the folder chosen, which
+// may not exist yet, or "" when cancelled.
+const char *DlgFolder(const char *title, const char *folder) {
+ return fileDialog(title, NULL, FD_FOLDER, folder);
+}
+
+
// The font dialog starting at family, size and style (FONT_STYLE_*
// flags; flags DVX_FONT_DLG_FIXED: fixed-pitch families only): True when
// the user chose OK. DlgFontName / DlgFontSize / DlgFontStyle then give
@@ -5349,9 +5369,9 @@ static const BasProcEntryT *eventHandler(BasFormRtT *rt, const BasControlT *obj,
}
-// The file dialogs of commdlg.bas: flags FD_OPEN or FD_SAVE, starting in
-// folder (NULL or "" = the current folder). The chosen path, or "" when
-// cancelled.
+// The file dialogs of commdlg.bas: flags FD_OPEN, FD_SAVE, or FD_FOLDER,
+// starting in folder (NULL or "" = the current folder), titled title (""
+// = the dialog's own). The chosen path, or "" when cancelled.
static const char *fileDialog(const char *title, const char *filter, int32_t flags, const char *folder) {
static char path[DVX_MAX_PATH];
FileFilterT *filters = NULL;
@@ -5364,7 +5384,7 @@ static const char *fileDialog(const char *title, const char *filter, int32_t fla
}
int32_t count = parseFileFilters(filter, &filters, filterBuf, sizeof(filterBuf));
- bool ok = dvxFileDialog(sFormRt->ctx, title, flags, (folder && folder[0]) ? folder : NULL, filters, count, path, sizeof(path));
+ bool ok = dvxFileDialog(sFormRt->ctx, (title && title[0]) ? title : NULL, flags, (folder && folder[0]) ? folder : NULL, filters, count, path, sizeof(path));
free(filters);
diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.h b/src/apps/kpunch/dvxbasic/formrt/formrt.h
index e195e264..a2e03824 100644
--- a/src/apps/kpunch/dvxbasic/formrt/formrt.h
+++ b/src/apps/kpunch/dvxbasic/formrt/formrt.h
@@ -96,6 +96,7 @@ typedef struct BasScreenWinT BasScreenWinT; // the program screen's window (f
#define BAS_PROP_CONTAINER BAS_CTRL_CONTAINER
#define BAS_PROP_CONTEXTMENU "ContextMenu"
#define BAS_PROP_CONTROLS BAS_FORM_CONTROLS
+#define BAS_PROP_DATABASENAME "DatabaseName"
#define BAS_PROP_DATAFIELD "DataField"
#define BAS_PROP_DATASOURCE "DataSource"
#define BAS_PROP_DEFAULT "Default"
diff --git a/src/apps/kpunch/dvxbasic/formrt/frmModelBas.c b/src/apps/kpunch/dvxbasic/formrt/frmModelBas.c
index 2c537659..538e99b4 100644
--- a/src/apps/kpunch/dvxbasic/formrt/frmModelBas.c
+++ b/src/apps/kpunch/dvxbasic/formrt/frmModelBas.c
@@ -37,6 +37,7 @@
#include "dvxMem.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
@@ -46,9 +47,6 @@
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define FRM_VALUE_MAX FRM_MAX_TEXT
#define FRM_SCRATCH_SIZE 64 // the hidden window new controls are made in to read their defaults
#define FRM_SCRATCH_TITLE ""
@@ -76,7 +74,7 @@ typedef struct {
// ============================================================
static BasFrmHandleT *sModels = NULL; // stb_ds; handle = index + 1
-static char *sText = NULL; // stb_ds: the last string result
+static BasTextT sText; // the last string result
static bool sCleanupHooked = false;
// ============================================================
@@ -132,7 +130,6 @@ static void liveMenuAdd(FrmLiveT *live, const char *name, const char *
static const char *liveMenuName(const BasFormT *form, const MenuT *menu);
static void liveMenus(FrmLiveT *live);
static FrmModelT *modelOf(int32_t handle);
-static const char *textOf(const char *s);
static void valueText(BasValueT v, uint8_t type, char *out, int32_t outSize);
@@ -188,7 +185,7 @@ const char *FormText(const char *formName) {
frmModelFree(m);
- const char *out = textOf(text ? text : "");
+ const char *out = basTextSet(&sText, text ? text : "");
free(text);
return out;
@@ -231,7 +228,7 @@ const char *FrmCtlGet(int32_t handle, int32_t i, const char *prop) {
val[0] = '\0';
}
- return textOf(val);
+ return basTextSet(&sText, val);
}
@@ -258,7 +255,7 @@ int32_t FrmCtlMove(int32_t handle, int32_t i, int32_t before) {
const char *FrmCtlName(int32_t handle, int32_t i) {
FrmCtrlT *c = ctrlOf(handle, i, NULL);
- return textOf(c ? c->name : "");
+ return basTextSet(&sText, c ? c->name : "");
}
@@ -298,7 +295,7 @@ int32_t FrmCtlPropCount(int32_t handle, int32_t i) {
const char *FrmCtlPropName(int32_t handle, int32_t i, int32_t j) {
FrmCtrlT *c = ctrlOf(handle, i, NULL);
- return textOf((c && j >= 0 && j < (int32_t)arrlen(c->props)) ? c->props[j].name : "");
+ return basTextSet(&sText, (c && j >= 0 && j < (int32_t)arrlen(c->props)) ? c->props[j].name : "");
}
@@ -357,7 +354,7 @@ const char *FrmCtlText(int32_t handle, int32_t i) {
FrmCtrlT *c = ctrlOf(handle, i, &m);
int32_t len = 0;
char *text = c ? frmModelCtrlText(m, c, &len) : NULL;
- const char *out = textOf(text ? text : "");
+ const char *out = basTextSet(&sText, text ? text : "");
free(text);
return out;
@@ -367,7 +364,7 @@ const char *FrmCtlText(int32_t handle, int32_t i) {
const char *FrmCtlType(int32_t handle, int32_t i) {
FrmCtrlT *c = ctrlOf(handle, i, NULL);
- return textOf(c ? c->typeName : "");
+ return basTextSet(&sText, c ? c->typeName : "");
}
@@ -387,14 +384,14 @@ const char *FrmGet(int32_t handle, const char *prop) {
val[0] = '\0';
}
- return textOf(val);
+ return basTextSet(&sText, val);
}
const char *FrmGetCode(int32_t handle) {
FrmModelT *m = modelOf(handle);
- return textOf((m && m->code) ? m->code : "");
+ return basTextSet(&sText, (m && m->code) ? m->code : "");
}
@@ -427,7 +424,7 @@ const char *FrmMenuGet(int32_t handle, int32_t i, const char *prop) {
val[0] = '\0';
}
- return textOf(val);
+ return basTextSet(&sText, val);
}
@@ -567,7 +564,7 @@ const char *FrmText(int32_t handle) {
FrmModelT *m = modelOf(handle);
int32_t len = 0;
char *text = m ? frmModelText(m, &len) : NULL;
- const char *out = textOf(text ? text : "");
+ const char *out = basTextSet(&sText, text ? text : "");
free(text);
return out;
@@ -898,21 +895,6 @@ static FrmModelT *modelOf(int32_t handle) {
}
-// Keep s as the last string result (valid until the next one).
-static const char *textOf(const char *s) {
- int32_t len = (int32_t)strlen(s);
-
- arrsetlen(sText, len + 1);
-
- if ((int32_t)arrlen(sText) != len + 1) {
- return "";
- }
-
- memcpy(sText, s, (size_t)len + 1);
- return sText;
-}
-
-
// A runtime property value as .frm text for its type.
static void valueText(BasValueT v, uint8_t type, char *out, int32_t outSize) {
if (type == WGT_IFACE_STRING) {
diff --git a/src/apps/kpunch/dvxbasic/ide/ideMain.c b/src/apps/kpunch/dvxbasic/ide/ideMain.c
index f1efaaea..110cb79a 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideMain.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideMain.c
@@ -58,6 +58,7 @@
#include "ideMenuEditor.h"
#include "ideToolbox.h"
#include "ideProperties.h"
+#include "idePackage.h"
#include "../basBuild.h"
#include "../basdev/basDebug.h"
@@ -126,6 +127,7 @@
#define IDE_NEW_CTL_FMT "UserControl%d" // name of a new UserControl
#define IDE_MAKE_EXE_LABEL "&Make Executable..." // File menu: a program project's build
#define IDE_MAKE_CTL_LABEL "&Make Control..." // File menu: a control project's build
+#define IDE_MAKE_PKG_LABEL "Make Pac&kage..." // File menu: the build as an add-on package
#define IDE_UC_DEFAULT_EVENT BAS_UC_EVT_INIT // the UserControl object's event when none has code yet
#define IDE_CTL_NO_RUN "A control project is not run: make its control file (File > Make Control), or add its .ctl files to a program project."
// Text of a new class module (%s = its name)
@@ -257,6 +259,7 @@
#define CMD_RECENT_MAX 16 // Keep cap: menu command-ID range is a fixed slice
#define CMD_MAKE_EXE 176
#define CMD_PRJ_NEW_CTL 177
+#define CMD_MAKE_PKG 178
#define CMD_HELP_CONTENTS 157
#define CMD_HELP_API 158
#define IDE_MAX_IMM 1024
@@ -505,6 +508,8 @@ static WidgetT *loadTbIcon(WidgetT *parent, const char *resName, const char *fal
static int32_t localToConcatLine(int32_t editorLine);
static void logFlushLine(void);
static void makeExecutable(void);
+static const char *makeOutput(const char *outPath, bool release);
+static void makePackage(void);
static void navigateToBreakpoint(int32_t bpIdx);
static int32_t navigateToCodeLine(int32_t fileIdx, int32_t codeLine, bool setDbgLine);
static void navigateToEventSub(void);
@@ -1184,6 +1189,7 @@ static void buildWindow(void) {
wmAddMenuItem(fileMenu, "Save A&ll", CMD_SAVE_ALL);
wmAddMenuSeparator(fileMenu);
wmAddMenuItem(fileMenu, IDE_MAKE_EXE_LABEL, CMD_MAKE_EXE);
+ wmAddMenuItem(fileMenu, IDE_MAKE_PKG_LABEL, CMD_MAKE_PKG);
wmAddMenuSeparator(fileMenu);
wmAddMenuItem(fileMenu, "R&ename File...", CMD_PRJ_RENAME);
wmAddMenuItem(fileMenu, "&Remove File", CMD_PRJ_REMOVE);
@@ -3062,6 +3068,12 @@ static void handleFileCmd(int32_t cmd) {
}
break;
+ case CMD_MAKE_PKG:
+ if (isIdle) {
+ makePackage();
+ }
+ break;
+
case CMD_EXIT:
if (sWin) {
onClose(sWin);
@@ -4165,7 +4177,7 @@ static void logFlushLine(void) {
// makeExecutable -- File > Make Executable: the program (.app), or for a
// control project (File > Make Control) its control file (.wgt, named
-// after the project), through the build code bascomp uses
+// after the project)
static void makeExecutable(void) {
if (!hasProject()) {
setStatus("Save the project first.");
@@ -4212,6 +4224,24 @@ static void makeExecutable(void) {
bool release = (modeChoice == 1);
setStatus(control ? "Building control file..." : (release ? "Building release executable..." : "Building debug executable..."));
+
+ const char *failure = makeOutput(outPath, release);
+
+ if (failure) {
+ setStatus(failure);
+ return;
+ }
+
+ char msg[IDE_MSG_BUF];
+ snprintf(msg, sizeof(msg), "Created %s (%s)", outPath, release ? "release" : "debug");
+ setStatus(msg);
+}
+
+
+// makeOutput -- build the compiled project (compileProject first) at
+// outPath: the program, or a control project's control file, through the
+// build code bascomp uses. NULL when built, else why not (IdeBuildFnT).
+static const char *makeOutput(const char *outPath, bool release) {
dvxSetBusy(sAc, true);
dvxUpdate(sAc);
@@ -4259,20 +4289,32 @@ static void makeExecutable(void) {
spec.fonts[i] = prjFontPath(&sProject, i);
}
- const char *failure = control ? basBuildControl(outPath, &spec) : basBuildApp(outPath, &spec);
+ const char *failure = prjIsControlProject(&sProject) ? basBuildControl(outPath, &spec) : basBuildApp(outPath, &spec);
freeTexts(frmSources);
freeTexts(ctlSources);
dvxSetBusy(sAc, false);
+ return failure;
+}
- if (failure) {
- setStatus(failure);
+
+// makePackage -- File > Make Package: the release build and its help as
+// an add-on package (idePackage.c)
+static void makePackage(void) {
+ if (!hasProject()) {
+ setStatus("Save the project first.");
return;
}
- char msg[IDE_MSG_BUF];
- snprintf(msg, sizeof(msg), "Created %s (%s)", outPath, release ? "release" : "debug");
- setStatus(msg);
+ if (!compileProject()) {
+ return;
+ }
+
+ const char *result = idePackageMake(sAc, &sProject, makeOutput);
+
+ if (result) {
+ setStatus(result);
+ }
}
@@ -8884,6 +8926,7 @@ static void updateProjectMenuState(void) {
// Save All: only when any file is dirty
wmMenuItemSetEnabled(sWin->menuBar, CMD_SAVE_ALL, anyFileModified());
wmMenuItemSetEnabled(sWin->menuBar, CMD_MAKE_EXE, project && isIdle);
+ wmMenuItemSetEnabled(sWin->menuBar, CMD_MAKE_PKG, project && isIdle);
// A control project makes a control file
MenuItemT *makeItem = sFileMenu ? wmMenuFindItemInMenu(sFileMenu, CMD_MAKE_EXE) : NULL;
@@ -9012,41 +9055,17 @@ static void watchEditSelected(void) {
// writeProjectFile -- write files[idx].buffer to disk (a NULL buffer is
-// an empty file), byte for byte. The text goes to a temporary sibling
-// file that platformReplaceFile swaps in only once every write
-// succeeded, so a failed save never destroys the copy on disk. Clears
-// the modified flags only on success; reports the failure otherwise.
+// an empty file), byte for byte, all or nothing (platformWriteFile), so a
+// failed save never destroys the copy on disk. Clears the modified flags
+// only on success; reports the failure otherwise.
static bool writeProjectFile(int32_t idx) {
PrjFileT *file = &sProject.files[idx];
const char *text = file->buffer ? file->buffer : "";
char fullPath[DVX_MAX_PATH];
- char tempPath[DVX_MAX_PATH];
- char bakPath[DVX_MAX_PATH];
prjFullPath(&sProject, idx, fullPath, sizeof(fullPath));
- bool ok = platformPathSibling(tempPath, sizeof(tempPath), fullPath, PLATFORM_TMP_EXT) && platformPathSibling(bakPath, sizeof(bakPath), fullPath, PLATFORM_BAK_EXT);
-
- if (ok) {
- size_t len = strlen(text);
- FILE *f = fopen(tempPath, "wb");
-
- ok = (f != NULL);
-
- if (f) {
- ok = (fwrite(text, 1, len, f) == len);
- ok = (fclose(f) == 0) && ok;
- }
-
- // The swap keeps the original on failure (and removes tempPath)
- if (ok) {
- ok = platformReplaceFile(tempPath, fullPath, bakPath);
- } else {
- remove(tempPath);
- }
- }
-
- if (!ok) {
+ if (!platformWriteFile(fullPath, text, (int32_t)strlen(text))) {
char msg[IDE_MSG_BUF];
snprintf(msg, sizeof(msg), "Could not write %s.", file->path);
diff --git a/src/apps/kpunch/dvxbasic/ide/idePackage.c b/src/apps/kpunch/dvxbasic/ide/idePackage.c
new file mode 100644
index 00000000..ef600c13
--- /dev/null
+++ b/src/apps/kpunch/dvxbasic/ide/idePackage.c
@@ -0,0 +1,337 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// idePackage.c -- File > Make Package (see idePackage.h)
+//
+// The package is the program (NAME.app, or a control project's NAME.wgt,
+// NAME the package name's second part) and its help file, built release
+// into IDE_PKG_SCRATCH_DIR and put in the kind's folder for the package
+// name (APPS\VENDOR\NAME, WIDGETS\VENDOR\NAME).
+// The manifest comes from the project: [Project] Name, Version,
+// Description (the summary), Publisher, and Author, and [Package] Id and
+// Needs; the oldest DVX it runs on is this one.
+
+#include "idePackage.h"
+#include "../basRes.h"
+#include "dvxDlg.h"
+#include "dvxPlat.h"
+#include "dvxTypes.h"
+#include "pkgDpk.h"
+#include "pkgManifest.h"
+#include "pkgSign.h"
+
+#include "thirdparty/stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ============================================================
+// Constants
+// ============================================================
+
+#define PKG_STATUS_MAX (PKG_ERR_MAX + DVX_MAX_PATH)
+#define PKG_KEYS_DIR "KEYS" // in the user folder: the keys DVX trusts, and the user's own
+#define PKG_KEYS_MAX 32 // keys offered to sign with
+#define PKG_KEY_ITEM_MAX (PKG_KEY_NAME_MAX + DVX_MAX_PATH)
+#define PKG_NOT_SIGNED "Not signed"
+#define PKG_NEED_SEP "," // between the needs of [Package] Needs
+#define PKG_NEED_BLANKS " \t" // between a need's id and version
+#define PKG_ID_DEFAULT_VENDOR "my" // a package name's vendor when the project has no publisher
+
+// The keys offered to sign with: their files and the list's lines
+typedef struct {
+ int32_t count;
+ char paths[PKG_KEYS_MAX][DVX_MAX_PATH];
+ char items[PKG_KEYS_MAX + 1][PKG_KEY_ITEM_MAX]; // "Not signed" first
+} KeyListT;
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static bool askId(AppContextT *ctx, PrjStateT *prj);
+static bool chooseKey(AppContextT *ctx, PrjStateT *prj, char *secretPath, int32_t secretSize);
+const char *idePackageMake(AppContextT *ctx, PrjStateT *prj, IdeBuildFnT build);
+static void keysList(KeyListT *keys);
+static bool manifestOf(const PrjStateT *prj, PkgManifestT *m);
+static const char *say(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
+static void scratchClear(const char *dir);
+
+// The status line idePackageMake returns
+static char sStatus[PKG_STATUS_MAX];
+
+
+// The project's package name: asked for (a suggestion from its publisher
+// and name to start with) until it is one; kept in the project. False
+// when the user cancelled.
+static bool askId(AppContextT *ctx, PrjStateT *prj) {
+ char vendor[PKG_ID_PART_MAX + 1];
+ char name[PKG_ID_PART_MAX + 1];
+ char id[PKG_ID_MAX];
+
+ pkgIdPartFrom(prj->publisher[0] ? prj->publisher : PKG_ID_DEFAULT_VENDOR, vendor);
+ pkgIdPartFrom(prj->name, name);
+ snprintf(id, sizeof(id), "%s%c%s", vendor, PKG_ID_SEP, name);
+
+ while (!pkgIdValid(prj->pkgId)) {
+ if (!dvxInputBox(ctx, IDE_PKG_TITLE_NAME, "The package name: who made it and what it is, with a dot between them, such as acme.chess (each 1 to 8 lower-case letters, digits, or _):", id, prj->pkgId, (int32_t)sizeof(prj->pkgId))) {
+ prj->pkgId[0] = '\0';
+ return false;
+ }
+
+ snprintf(id, sizeof(id), "%s", prj->pkgId);
+ }
+
+ prjSave(prj);
+ return true;
+}
+
+
+// The key to sign with: one of the user's keys, or none. The one chosen
+// last is offered first, and the choice is kept in the project. False
+// when the user cancelled.
+static bool chooseKey(AppContextT *ctx, PrjStateT *prj, char *secretPath, int32_t secretSize) {
+ KeyListT *keys = (KeyListT *)calloc(1, sizeof(KeyListT));
+ const char *items[PKG_KEYS_MAX + 1];
+ int32_t choice = 0;
+ bool ok;
+
+ if (!keys) {
+ return false;
+ }
+
+ keysList(keys);
+
+ for (int32_t i = 0; i <= keys->count; i++) {
+ items[i] = keys->items[i];
+
+ if (i > 0 && platformPathSame(keys->paths[i - 1], prj->pkgKey)) {
+ choice = i;
+ }
+ }
+
+ ok = dvxChoiceDialog(ctx, IDE_PKG_TITLE_SIGN, "Sign the package with:", items, keys->count + 1, choice, &choice);
+
+ if (ok) {
+ snprintf(secretPath, (size_t)secretSize, "%s", choice > 0 ? keys->paths[choice - 1] : "");
+
+ if (!platformPathSame(secretPath, prj->pkgKey)) {
+ snprintf(prj->pkgKey, sizeof(prj->pkgKey), "%s", secretPath);
+ prjSave(prj);
+ }
+ }
+
+ free(keys);
+ return ok;
+}
+
+
+const char *idePackageMake(AppContextT *ctx, PrjStateT *prj, IdeBuildFnT build) {
+ static const FileFilterT filters[] = {
+ { "Packages (*.dpk)" },
+ { "All Files (*.*)" }
+ };
+ char outPath[DVX_MAX_PATH];
+ char secretPath[DVX_MAX_PATH];
+ char scratch[DVX_MAX_PATH];
+ char program[DVX_MAX_PATH];
+ char fileName[DVX_MAX_PATH];
+ char vendor[PKG_ID_PART_MAX + 1];
+ char pkgName[PKG_ID_PART_MAX + 1];
+ char folder[PKG_NAME_MAX];
+ char err[PKG_ERR_MAX];
+ PkgManifestT m;
+ PkgSecretT secret;
+ PkgDpkFileT *files = NULL;
+ const char *failure = NULL;
+ bool control = prjIsControlProject(prj);
+ bool ok;
+
+ if (!pkgIdValid(prj->pkgId) && !askId(ctx, prj)) {
+ return NULL;
+ }
+
+ pkgManifestInit(&m);
+
+ if (!manifestOf(prj, &m)) {
+ pkgManifestFree(&m);
+ return sStatus;
+ }
+
+ // Files named for the package (a DOS name, as the project's may not be)
+ pkgIdSplit(m.id, vendor, pkgName);
+ snprintf(outPath, sizeof(outPath), "%s%s", pkgName, PKG_EXT);
+
+ if (!dvxFileDialog(ctx, IDE_PKG_TITLE_MAKE, FD_SAVE, NULL, filters, DVX_ARRAY_LEN(filters), outPath, sizeof(outPath)) || !chooseKey(ctx, prj, secretPath, sizeof(secretPath))) {
+ pkgManifestFree(&m);
+ return NULL;
+ }
+
+ // The program built where nothing else is
+ const char *temp = platformTempDir();
+ const char *base = (temp && temp[0]) ? temp : platformUserDir();
+
+ snprintf(fileName, sizeof(fileName), "%s%s", pkgName, control ? BAS_WGT_EXT : BAS_APP_EXT);
+ ok = platformPathJoin(scratch, sizeof(scratch), base, IDE_PKG_SCRATCH_DIR);
+ scratchClear(scratch);
+ ok = ok && platformMkdirRecursive(scratch) == 0 && platformPathJoin(program, sizeof(program), scratch, fileName);
+
+ if (!ok) {
+ failure = say("Cannot make the folder %s.", scratch);
+ } else {
+ failure = build(program, true);
+ }
+
+ if (!failure && !pkgKindFolder(m.kind, m.id, folder, sizeof(folder))) {
+ failure = say("%s is not a package name.", m.id);
+ }
+
+ if (!failure && !pkgDpkFolderFiles(scratch, folder, &files)) {
+ failure = say("Cannot read the folder %s.", scratch);
+ }
+
+ if (!failure && secretPath[0] && !pkgSecretRead(secretPath, &secret, err, sizeof(err))) {
+ failure = say("%s", err);
+ }
+
+ if (!failure && !pkgDpkBuildFiles(&m, files, (int32_t)arrlen(files), outPath, secretPath[0] ? &secret : NULL, err, sizeof(err))) {
+ failure = say("%s", err);
+ }
+
+ pkgSecretWipe(&secret);
+ pkgDpkFilesFree(files);
+ pkgManifestFree(&m);
+ scratchClear(scratch);
+
+ if (failure) {
+ return failure;
+ }
+
+ return say("Created %s (%s)", outPath, secretPath[0] ? "signed" : "not signed");
+}
+
+
+// The user's secret keys (the KEYS folder of the user folder), by the
+// names in them, after "Not signed".
+static void keysList(KeyListT *keys) {
+ char dir[DVX_MAX_PATH];
+ char err[PKG_ERR_MAX];
+ DIR *d;
+ struct dirent *ent;
+
+ snprintf(keys->items[0], sizeof(keys->items[0]), "%s", PKG_NOT_SIGNED);
+
+ if (!platformUserPath(dir, sizeof(dir), PKG_KEYS_DIR) || (d = opendir(dir)) == NULL) {
+ return;
+ }
+
+ while (keys->count < PKG_KEYS_MAX && (ent = readdir(d)) != NULL) {
+ char *path = keys->paths[keys->count];
+ PkgSecretT secret;
+
+ if (!dvxHasExt(ent->d_name, PKG_SECRET_EXT) || !platformPathJoin(path, DVX_MAX_PATH, dir, ent->d_name) || !pkgSecretRead(path, &secret, err, sizeof(err))) {
+ continue;
+ }
+
+ snprintf(keys->items[keys->count + 1], sizeof(keys->items[0]), "%s (%s)", secret.name, ent->d_name);
+ pkgSecretWipe(&secret);
+ keys->count++;
+ }
+
+ closedir(d);
+}
+
+
+// The package's manifest from the project (its files come later); false
+// with the reason in sStatus when the project's details will not do.
+static bool manifestOf(const PrjStateT *prj, PkgManifestT *m) {
+ const char *p = prj->pkgNeeds;
+
+ snprintf(m->id, sizeof(m->id), "%s", prj->pkgId);
+ snprintf(m->name, sizeof(m->name), "%s", prj->name);
+ snprintf(m->summary, sizeof(m->summary), "%s", prj->description);
+ snprintf(m->publisher, sizeof(m->publisher), "%s", prj->publisher);
+ snprintf(m->author, sizeof(m->author), "%s", prj->author);
+ m->kind = prjIsControlProject(prj) ? PkgKindWidgetE : PkgKindAppE;
+ m->dvx.major = DVX_VERSION_MAJOR;
+ m->dvx.minor = DVX_VERSION_MINOR;
+ m->dvx.patch = DVX_VERSION_PATCH;
+
+ if (!pkgVersionParse(prj->version, &m->version)) {
+ say("The project's version (Project Properties) must be numbers with dots between them, such as 1.0.0.");
+ return false;
+ }
+
+ // Needs: "id version, id version" (no version: any)
+ for (p += strspn(p, PKG_NEED_SEP PKG_NEED_BLANKS); *p; p += strspn(p, PKG_NEED_SEP PKG_NEED_BLANKS)) {
+ char id[PKG_ID_MAX];
+ char ver[PKG_VERSION_MAX];
+ PkgVersionT min = { 0, 0, 0 };
+ size_t idLen = strcspn(p, PKG_NEED_SEP PKG_NEED_BLANKS);
+ const char *v = p + idLen + strspn(p + idLen, PKG_NEED_BLANKS);
+ size_t verLen = (*v && !strchr(PKG_NEED_SEP, *v)) ? strcspn(v, PKG_NEED_SEP PKG_NEED_BLANKS) : 0;
+
+ snprintf(id, sizeof(id), "%.*s", (int)idLen, p);
+ snprintf(ver, sizeof(ver), "%.*s", (int)verLen, v);
+
+ if (idLen >= sizeof(id) || verLen >= sizeof(ver) || !pkgIdValid(id) || (verLen > 0 && !pkgVersionParse(ver, &min))) {
+ say("The project's needs (Project Properties) must be package names, each with the oldest version that will do, such as acme.cards 1.0, with commas between them: %.*s is not.", (int)idLen, p);
+ return false;
+ }
+
+ pkgManifestAddDepend(m, id, min);
+ p = v + verLen;
+ }
+
+ return true;
+}
+
+
+// The status line, as printf, in sStatus.
+static const char *say(const char *fmt, ...) {
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(sStatus, sizeof(sStatus), fmt, ap);
+ va_end(ap);
+ return sStatus;
+}
+
+
+// Delete the scratch folder's files (left by a build that stopped, or
+// this one's), then the folder.
+static void scratchClear(const char *dir) {
+ PkgDpkFileT *files = NULL;
+
+ pkgDpkFolderFiles(dir, "", &files);
+
+ for (int32_t i = 0; i < (int32_t)arrlen(files); i++) {
+ remove(files[i].source);
+ }
+
+ pkgDpkFilesFree(files);
+ rmdir(dir);
+}
diff --git a/src/apps/kpunch/dvxbasic/ide/idePackage.h b/src/apps/kpunch/dvxbasic/ide/idePackage.h
new file mode 100644
index 00000000..e11b74af
--- /dev/null
+++ b/src/apps/kpunch/dvxbasic/ide/idePackage.h
@@ -0,0 +1,56 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// idePackage.h -- File > Make Package: the project's program (or, for a
+// control project, its control file) as an add-on package, NAME.DPK, for
+// Software to install
+
+#ifndef IDE_PACKAGE_H
+#define IDE_PACKAGE_H
+
+#include "../basdev/basProject.h"
+#include "dvxApp.h"
+
+#include
+
+// Its dialogs: the package name (asked once), the package file, the key
+#define IDE_PKG_TITLE_NAME "Package Name"
+#define IDE_PKG_TITLE_MAKE "Make Package"
+#define IDE_PKG_TITLE_SIGN "Sign Package"
+
+// Where the program is built for the package: in TEMP, or the user
+// folder when there is none (emptied and removed after)
+#define IDE_PKG_SCRATCH_DIR "PKGMAKE"
+
+// Build the program (or control file) at outPath, a release build when
+// release: NULL when it is built, else why not.
+typedef const char *(*IdeBuildFnT)(const char *outPath, bool release);
+
+// Make the project's package: its package name asked for when the
+// project has none (and kept in it, as the key chosen is), the package
+// file and the key to sign with asked for, the program built by build
+// into a scratch folder, and the package made from it and the project's
+// details ([Project] and [Package]). The status line to show: the package
+// made, or why not; NULL when the user cancelled.
+const char *idePackageMake(AppContextT *ctx, PrjStateT *prj, IdeBuildFnT build);
+
+#endif // IDE_PACKAGE_H
diff --git a/src/apps/kpunch/dvxbasic/ide/ideProject.c b/src/apps/kpunch/dvxbasic/ide/ideProject.c
index 995be34d..b7db97ac 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideProject.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideProject.c
@@ -119,6 +119,8 @@ static struct {
WidgetT *copyright;
WidgetT *description;
WidgetT *commandArgs;
+ WidgetT *pkgId;
+ WidgetT *pkgNeeds;
WidgetT *startupForm;
WidgetT *optionExplicit;
WidgetT *multiInstance;
@@ -715,6 +717,10 @@ bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath)
// What COMMAND$ returns when the program is run from the IDE.
sPpd.commandArgs = ppdAddRow(root, "Arguments:", prj->commandArgs, sizeof(prj->commandArgs));
+ // File > Make Package: the package name and what it needs
+ sPpd.pkgId = ppdAddRow(root, "Package:", prj->pkgId, sizeof(prj->pkgId));
+ sPpd.pkgNeeds = ppdAddRow(root, "Needs:", prj->pkgNeeds, sizeof(prj->pkgNeeds));
+
// OK / Cancel buttons
WidgetT *btnRow = wgtHBox(root);
btnRow->align = AlignCenterE;
@@ -747,6 +753,8 @@ bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath)
ppdReadInput(sPpd.description, prj->description, sizeof(prj->description));
ppdReadInput(sPpd.helpFileInput, prj->helpFile, sizeof(prj->helpFile));
ppdReadInput(sPpd.commandArgs, prj->commandArgs, sizeof(prj->commandArgs));
+ ppdReadInput(sPpd.pkgId, prj->pkgId, sizeof(prj->pkgId));
+ ppdReadInput(sPpd.pkgNeeds, prj->pkgNeeds, sizeof(prj->pkgNeeds));
snprintf(prj->iconPath, sizeof(prj->iconPath), "%s", sPpd.iconPath);
// Read startup form from dropdown
diff --git a/src/apps/kpunch/dvxbasic/ide/ideProperties.c b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
index 17bd230a..0fffd44b 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideProperties.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
@@ -26,11 +26,13 @@
// form (for selection and drag-reorder) and a ListView showing
// editable properties of the selected control. Double-click a
// property value to edit it via an InputBox dialog (the Font row in the
-// font dialog: the six Font properties at once). While a UserControl
+// font dialog: the six Font properties at once; a picture or database
+// file in a box with Browse..., editPath). While a UserControl
// (.ctl) is the active file the window shows its header instead (Name,
// Width, Height ... DefaultEvent: prjCtlKeyAt), edited by kind: a number
// in a spin box, a flag toggled, BackColor in the color dialog, the
-// default event picked from the control's Events, text in an input box.
+// default event picked from the control's Events, ToolboxIcon with
+// Browse..., text in an input box.
#include "ideProperties.h"
#include "../basdev/basProject.h"
@@ -40,6 +42,7 @@
#include "dvxDlg.h"
#include "dvxWm.h"
#include "box/box.h"
+#include "button/button.h"
#include "listView/listView.h"
#include "splitter/splitter.h"
#include "treeView/treeView.h"
@@ -77,6 +80,9 @@
#define PRP_EDIT_TITLE "Edit Property"
#define PRP_CTL_MIN_SIZE 1 // smallest UserControl Width / Height
#define PRP_CTL_DEF_COLOR 0xFFFFFF // BackColor the color dialog starts from when the header gives none
+#define PRP_PATH_DLG_W 360 // the path editor's width
+#define PRP_PATH_LABEL_W 110 // and its label's
+#define PRP_PATH_FILTERS 2 // a path kind's file dialog filters
#define PRP_CTL_HEX_PREFIX "&H" // a hexadecimal color in the header
#define PRP_CTL_HEX_FMT PRP_CTL_HEX_PREFIX "%06" PRIX32 // a BASIC color as the header takes it
#define PRP_HEX_BASE 16
@@ -90,15 +96,34 @@ typedef enum {
PRP_EDIT_DATASOURCE,
PRP_EDIT_DATAFIELD,
PRP_EDIT_RECORDSRC,
- PRP_EDIT_FONT
+ PRP_EDIT_FONT,
+ PRP_EDIT_PICTURE,
+ PRP_EDIT_DATABASE
} PrpEditE;
+// What a path row holds: the file dialog's title and filters
+typedef struct {
+ const char *title;
+ const FileFilterT filters[PRP_PATH_FILTERS];
+} PrpPathKindT;
+
+// The path editor's dialog: its box, what it browses for, and the answer
+typedef struct {
+ WidgetT *input;
+ const PrpPathKindT *kind;
+ char *out;
+ int32_t outSize;
+} PrpPathDlgT;
+
// The Font row's words for the style flags, in the order they show
typedef struct {
uint32_t style;
const char *word;
} PrpFontWordT;
+static const PrpPathKindT sPicturePaths = { "Choose Picture", { { "Pictures (*.bmp;*.png;*.jpg;*.gif)" }, { "All Files (*.*)" } } };
+static const PrpPathKindT sDatabasePaths = { "Choose Database", { { "Databases (*.db)" }, { "All Files (*.*)" } } };
+
static const PrpFontWordT sFontWords[] = {
{ FONT_STYLE_BOLD, "Bold" },
{ FONT_STYLE_ITALIC, "Italic" },
@@ -157,6 +182,7 @@ static void ctlAddEvent(void *ctx, const char *name, const char *p
static void ctlEditRow(int32_t row);
static void ctlShowRows(void);
static void editFont(void);
+static bool editPath(const char *name, const char *curValue, const PrpPathKindT *kind, char *out, int32_t outSize);
static WidgetT *findTreeItemByName(WidgetT *parent, const char *name, int32_t index);
static void freeCellData(void);
static void freeTreeLabels(void);
@@ -168,8 +194,10 @@ static void onPrpClose(WindowT *win);
static void onTreeChange(WidgetT *w);
static void onTreeItemClick(WidgetT *w);
static void parseTreeLabel(const char *label, char *outName, int32_t outNameSize, int32_t *outIndex);
+static void pathBrowse(WidgetT *w);
+static bool pathDlgOk(WindowT *dlg, void *userCtx);
static bool propDropdownOrInput(AppContextT *ctx, const char *propName, const char *selectPrompt, char (*names)[FRM_MAX_NAME], int32_t count, const char *curValue, char *newValue, int32_t newValueSize);
-static void resolveDbPath(const char *dbName, char *out, int32_t outSize);
+static void resolveProjectPath(const char *name, char *out, int32_t outSize);
static bool treeOrderMatches(void);
static bool validateNewName(const char *name, int32_t maxLen, const char *exceptName);
@@ -319,6 +347,10 @@ static void ctlEditRow(int32_t row) {
if (!picked) {
return;
}
+ } else if (strcasecmp(key, BAS_UC_KEY_TOOLBOXICON) == 0) {
+ if (!editPath(key, curValue, &sPicturePaths, newValue, (int32_t)sizeof(newValue))) {
+ return;
+ }
} else if (!dvxInputBox(sPrpCtx, PRP_EDIT_TITLE, prompt, curValue, newValue, (int32_t)sizeof(newValue))) {
return;
}
@@ -347,7 +379,6 @@ static void ctlShowRows(void) {
}
-// Walk tree items recursively to find the one matching a control name.
// The Font row: the font dialog starting at the font the selected control
// (or the form) draws with, fixed-pitch families only for a control that
// edits text by columns. OK stores all six Font properties, or removes
@@ -405,6 +436,34 @@ static void editFont(void) {
}
+// A path row's editor: Edit Property with the value in a box and
+// Browse... beside it (pathBrowse). False when cancelled.
+static bool editPath(const char *name, const char *curValue, const PrpPathKindT *kind, char *out, int32_t outSize) {
+ PrpPathDlgT pd;
+ char label[PRP_PROMPT_BUF];
+ WindowT *dlg = dvxDialogCreate(sPrpCtx, PRP_EDIT_TITLE, PRP_PATH_DLG_W);
+
+ if (!dlg) {
+ return false;
+ }
+
+ snprintf(label, sizeof(label), "%s:", name);
+ pd.input = dvxDialogAddField(dlg, label, PRP_PATH_LABEL_W, outSize - 1, curValue);
+ pd.kind = kind;
+ pd.out = out;
+ pd.outSize = outSize;
+
+ WidgetT *browse = wgtButton(pd.input->parent, "&Browse...");
+
+ browse->userData = &pd;
+ browse->onClick = pathBrowse;
+ dvxDialogAddOkCancel(dlg, "&OK", pathDlgOk, &pd);
+ wgtSetFocused(pd.input);
+ return dvxDialogRun(sPrpCtx, dlg);
+}
+
+
+// Walk tree items recursively to find the one matching a control name.
static WidgetT *findTreeItemByName(WidgetT *parent, const char *name, int32_t index) {
for (WidgetT *item = parent->firstChild; item; item = item->nextSibling) {
const char *label = (const char *)item->userData;
@@ -480,7 +539,7 @@ static int32_t getDataFieldNames(const DsgnStateT *ds, const char *dataSourceNam
continue;
}
- dbName = frmCtrlGetProp(ctrl, "DatabaseName");
+ dbName = frmCtrlGetProp(ctrl, BAS_PROP_DATABASENAME);
recSrc = frmCtrlGetProp(ctrl, BAS_PROP_RECORDSOURCE);
break;
}
@@ -502,7 +561,7 @@ static int32_t getDataFieldNames(const DsgnStateT *ds, const char *dataSourceNam
}
char fullPath[DVX_MAX_PATH];
- resolveDbPath(dbName, fullPath, sizeof(fullPath));
+ resolveProjectPath(dbName, fullPath, sizeof(fullPath));
int32_t db = sqlOpen(fullPath);
@@ -574,8 +633,8 @@ static int32_t getDataFieldNames(const DsgnStateT *ds, const char *dataSourceNam
static uint8_t getPropType(const char *propName, const char *typeName) {
// Designer rows with a fixed editor: the read-only Type/Index rows,
// the Font row (the font dialog), Name and Layout (editable at design
- // time although the runtime rejects assignment), and the data-binding
- // pickers.
+ // time although the runtime rejects assignment), the data-binding
+ // pickers, and the file paths (picture and database files).
static const struct {
const char *name;
uint8_t kind;
@@ -591,7 +650,11 @@ static uint8_t getPropType(const char *propName, const char *typeName) {
{ BAS_PROP_KEYCOLUMN, PRP_EDIT_DATAFIELD },
{ BAS_PROP_MASTERSOURCE, PRP_EDIT_DATASOURCE },
{ BAS_PROP_MASTERFIELD, PRP_EDIT_DATAFIELD },
- { BAS_PROP_DETAILFIELD, PRP_EDIT_DATAFIELD }
+ { BAS_PROP_DETAILFIELD, PRP_EDIT_DATAFIELD },
+ { BAS_PROP_PICTURE, PRP_EDIT_PICTURE },
+ { BAS_PROP_DRAGICON, PRP_EDIT_PICTURE },
+ { BAS_PROP_ICON, PRP_EDIT_PICTURE },
+ { BAS_PROP_DATABASENAME, PRP_EDIT_DATABASE }
};
for (int32_t i = 0; i < DVX_ARRAY_LEN(fixedKinds); i++) {
@@ -638,7 +701,7 @@ static int32_t getTableNames(const char *dbName, char (**outNames)[FRM_MAX_NAME]
}
char fullPath[DVX_MAX_PATH];
- resolveDbPath(dbName, fullPath, sizeof(fullPath));
+ resolveProjectPath(dbName, fullPath, sizeof(fullPath));
int32_t db = sqlOpen(fullPath);
@@ -898,7 +961,7 @@ static void onPropDblClick(WidgetT *w) {
if (sDs->selectedIdx >= 0 && sDs->selectedIdx < selCount) {
FrmCtrlT *selCtrl = sDs->form->controls[sDs->selectedIdx];
- const char *dbNameProp = frmCtrlGetProp(selCtrl, "DatabaseName");
+ const char *dbNameProp = frmCtrlGetProp(selCtrl, BAS_PROP_DATABASENAME);
if (dbNameProp) {
dbName = dbNameProp;
@@ -914,6 +977,10 @@ static void onPropDblClick(WidgetT *w) {
if (!ok) {
return;
}
+ } else if (propType == PRP_EDIT_PICTURE || propType == PRP_EDIT_DATABASE) {
+ if (!editPath(propName, curValue, (propType == PRP_EDIT_PICTURE) ? &sPicturePaths : &sDatabasePaths, newValue, (int32_t)sizeof(newValue))) {
+ return;
+ }
} else if (propType == WGT_IFACE_INT) {
// Spinner dialog for integers
char prompt[PRP_PROMPT_BUF];
@@ -1210,6 +1277,42 @@ static void parseTreeLabel(const char *label, char *outName, int32_t outNameSize
}
+// Browse... in the path editor: the Open dialog over the kind's filters,
+// starting in the folder of the path typed (resolveProjectPath), else the
+// project's; a file in the project folder (or below it) goes in relative
+// to it, as the designer and the program read it.
+static void pathBrowse(WidgetT *w) {
+ PrpPathDlgT *pd = (PrpPathDlgT *)w->userData;
+ const char *home = (sDs && sDs->projectDir) ? sDs->projectDir : NULL;
+ const char *value = dvxSkipWs(wgtGetText(pd->input));
+ char typed[DVX_MAX_PATH];
+ char dir[DVX_MAX_PATH];
+ char path[DVX_MAX_PATH];
+
+ resolveProjectPath(value, typed, sizeof(typed));
+
+ bool inTyped = value[0] && platformPathDirName(typed, dir, sizeof(dir));
+
+ if (!dvxFileDialog(sPrpCtx, pd->kind->title, FD_OPEN, inTyped ? dir : home, pd->kind->filters, PRP_PATH_FILTERS, path, sizeof(path))) {
+ return;
+ }
+
+ const char *rel = home ? platformPathIsUnder(path, home) : NULL;
+
+ wgtSetText(pd->input, (rel && rel[0]) ? rel : path);
+}
+
+
+// The path editor's OK: the box's text, trimmed.
+static bool pathDlgOk(WindowT *dlg, void *userCtx) {
+ PrpPathDlgT *pd = (PrpPathDlgT *)userCtx;
+
+ (void)dlg;
+ dvxCopyTrimmed(pd->out, pd->outSize, wgtGetText(pd->input));
+ return true;
+}
+
+
// propDropdownOrInput -- shared editor for the DataField / RecordSource
// properties. When count <= 0 it falls back to a free-text input box;
// otherwise it offers a "(none)"-prefixed dropdown of names, preselecting
@@ -1610,11 +1713,11 @@ void prpShowCtl(const char *text, PrpCtlSetFnT onSet) {
}
-// resolveDbPath -- resolve a DatabaseName against the project directory
-static void resolveDbPath(const char *dbName, char *out, int32_t outSize) {
- // An absolute name stands as it is; else it counts from the project
- // folder (platformPathJoin)
- platformPathJoin(out, outSize, (sDs && sDs->projectDir) ? sDs->projectDir : "", dbName);
+// A file name a property holds (a DatabaseName, a Picture) as a path: an
+// absolute name stands as it is, else it counts from the project folder
+// (platformPathJoin).
+static void resolveProjectPath(const char *name, char *out, int32_t outSize) {
+ platformPathJoin(out, outSize, (sDs && sDs->projectDir) ? sDs->projectDir : "", name);
}
diff --git a/src/apps/kpunch/dvxbasic/ideguide.dhs b/src/apps/kpunch/dvxbasic/ideguide.dhs
index 748a90e3..abf5dff1 100644
--- a/src/apps/kpunch/dvxbasic/ideguide.dhs
+++ b/src/apps/kpunch/dvxbasic/ideguide.dhs
@@ -117,6 +117,7 @@ The IDE compiles BASIC source into bytecode and runs it in an integrated virtual
---
Make Executable... Compile the project and save it as a standalone .app file. Prompts for a debug or release build. A release build leaves out debugging information and gives forms and controls short internal names, except in a program that uses names as text (the Name property, Controls, DesignSelection, or a DesignMouse event procedure), which keeps them.
Make Control... In a control project (Project Properties, Type Control) this item replaces Make Executable: it compiles the project and saves its controls as a control file (.wgt, named after the project). See UserControls in the IDE.
+ Make Package... Make the program (or, in a control project, the control file) and its help file into an add-on package (.DPK) that anyone can install with Software. See Making a Package.
---
Rename File... Give the file selected in the Project Explorer a new name, on disk and in the project. See Renaming a File under Project System.
Remove File Remove the selected file from the project (prompts to save if modified).
@@ -539,7 +540,7 @@ These checks use the same metadata the Properties panel and Toolbox read from ea
New UserControl Adds a new UserControl to the project and opens it (see UserControls in the IDE).
Rename File Gives a file a new name on disk and in the project file (see Renaming a File below).
Remove File Removes a file from the project (prompts to save if modified). Removing the startup form clears the Startup Form setting.
- Project Properties Opens a dialog for editing project metadata (name, type, author, company, version, copyright, description, icon, help file, startup form), the fonts the program carries, whether more than one copy may run at once, and the Arguments for runs from the IDE.
+ Project Properties Opens a dialog for editing project metadata (name, type, author, company, version, copyright, description, icon, help file, startup form), the fonts the program carries, whether more than one copy may run at once, the Arguments for runs from the IDE, and the Package and Needs that File > Make Package uses.
.endtable
.h2 Renaming a File
@@ -579,6 +580,30 @@ Leave the VERSION and Attribute lines at the top of the file; they are not compi
.link ide.usercontrols UserControls in the IDE
.link ide.overview Back to Overview
+.topic ide.package
+.title Making a Package
+.toc 1 Making a Package
+.index Make Package
+.index Packages
+.index DPK files
+.index Package name
+.index Signing a package
+
+.h1 Making a Package
+
+File > Make Package turns the project into an add-on package: one .DPK file holding the program (built as a release build) and its help file, which anyone can install with the Software program -- it puts the program in Program Manager's Installed Programs -- and remove again. In a control project the package holds the control file instead, and installs it for every program to use.
+
+The first time, Make Package asks for the package name: who made it and what it is, with a dot between them, such as acme.chess (each part 1 to 8 lower-case letters, digits, or _). It suggests one from the project's Publisher and Name. Keep the same package name for every version, so that Software installs a new version over the old one. The name is kept in the project and can be changed under File > Project Properties (Package).
+
+It then asks where to save the package and which key to sign it with. Signing tells the people who install the package that it came from you and was not changed on the way; the list offers the keys in your KEYS folder (make one with Package Builder's New Key) and Not signed. The key chosen is offered first the next time.
+
+The package takes the rest from the project: its name, Version (give each version you hand out a higher one, such as 1.0.0, then 1.1.0), Description (shown as its summary), Publisher, and Author. When the program needs other add-on packages to work, list them under Project Properties, Needs: each package name with the oldest version that will do, with commas between them, such as acme.cards 1.0, acme.dice 2.1 (a package name alone takes any version). DVX's own libraries need not be listed.
+
+A program that needs more files than its help -- data files, or files in other folders -- is packaged with Package Builder, which takes a whole folder.
+
+.link ide.project Project System
+.link ide.overview Back to Overview
+
.topic ide.usercontrols
.title UserControls in the IDE
.toc 1 UserControls in the IDE
@@ -637,7 +662,7 @@ While a UserControl is the active file, the Properties window shows its header i
Width, Height Enter a whole number of pixels (at least 1): the control's natural size.
CanGetFocus Each double-click switches between True and False.
BackColor Pick a color in the color dialog.
- ToolboxIcon Type the name of a 24 x 24 picture file in the project's folder.
+ ToolboxIcon Type the name of a 24 x 24 picture file in the project's folder, or click Browse... to pick it.
Description Type the toolbox tooltip.
DefaultEvent Pick one of the control's Events (or none): the event the designer opens when a placed control is double-clicked.
.endtable
@@ -685,7 +710,7 @@ The Properties panel (Window > Properties) has two sections:
.list
.item Control tree -- a TreeView at the top listing the form and all its controls in layout order. Click a control name to select it in both the Properties panel and the Form Designer. Drag items in the tree to reorder controls in the form's layout.
-.item Property list -- a two-column ListView below the tree showing property names and values for the selected control. Double-click a property value to edit it via an InputBox dialog. Changes take effect immediately in the designer preview.
+.item Property list -- a two-column ListView below the tree showing property names and values for the selected control. Double-click a property value to edit it via an InputBox dialog. A file property (Picture, DragIcon, the form's Icon, and a Data control's DatabaseName) has Browse... beside its box: a file picked in the project's folder (or a folder inside it) is kept relative to the project, so the project still works when it is moved. Changes take effect immediately in the designer preview.
.endlist
Each control type exposes different properties (e.g., Caption, Text, Width, Height, MaxWidth, MaxHeight, Weight, Alignment, Enabled, Visible, and type-specific properties like DataSource and DataField for data-bound controls). A UserControl placed on a form lists the properties its code defines.
@@ -1343,6 +1368,7 @@ DVX BASIC now uses the QBasic scope rules. A SUB or FUNCTION sees a variable dec
.item Paths may be written with / or \ wherever a path is taken (OPEN, KILL, NAME, MKDIR, CHDIR, DIR$, FILELEN, and the file and folder functions of the libraries), mixed too; paths DVX gives back (CURDIR$, App.Path, App.Data, the file dialogs, the FileOps results) use \, as DOS shows them. New: FileOpsPathNative$ gives a path of your own that form. DIR$("C:\*.*") lists the root of C: (before, it listed C:'s current folder). SHELL commands still go to DOS as written, where / starts a switch.
.item The IDE shows a project's file paths with \ and keeps writing them with / in the .dbp file, so a project reads the same everywhere.
.item The Open and Save As dialogs (DlgFileOpen$, DlgFileSave$, and the IDE's own) have a Drive list beside the Path box (Alt+D); a drive with no disk in it says it is not ready.
+.item New: DlgFolder$(title, folder) in commdlg.bas lets the user choose a folder, existing or new, for a Browse button beside a folder box. A title of "" gives any of the file dialogs its usual title (it used to leave the title bar empty).
.item Changed: FileOpsValidName takes the folder the name is for, FileOpsValidName(folder, itemName), since the rules depend on its disk; XferSafeName$ takes the folder a file is received into, XferSafeName$(name, folder). New: FileOpsLongNames, FileOpsShortPath$, FileOpsValidLongName, and FileOpsValidShortName (fileops.bas), and ShellArgQuote$ (shell.bas), which puts a path with spaces in quotes for a command line.
.item Programs compiled with an older release must be rebuilt. A program whose procedure table records more than 64 locals for a procedure is refused when it is loaded.
.item A class may have at most 65536 member variables and a form at most 65536 form-level variables (as many as the compiled program can number); more is a compile error ("Too many member variables in X", "Too many form-level variables"), and a program file that records more is refused when it is loaded.
@@ -1389,6 +1415,7 @@ DVX BASIC now uses the QBasic scope rules. A SUB or FUNCTION sees a variable dec
.h2 IDE
.list
+.item Properties window: Picture, DragIcon, the form's Icon, DatabaseName, and a UserControl's ToolboxIcon have Browse... beside the box; a file in the project's folder goes in relative to it.
.item Immediate window: Enter evaluates the line it is pressed on, assignments accept any expression, and a statement that does not finish is stopped.
.item A compile error puts the caret on the column of the error, not just its line.
.item Output window: always shows the newest output and drops the oldest lines when full; Output Window to Log also logs errors.
diff --git a/src/apps/kpunch/dvxbasic/langref.dhs b/src/apps/kpunch/dvxbasic/langref.dhs
index 93b9595c..e68c8bab 100644
--- a/src/apps/kpunch/dvxbasic/langref.dhs
+++ b/src/apps/kpunch/dvxbasic/langref.dhs
@@ -4871,9 +4871,12 @@ Colors in the form RGB makes (&HBBGGRR, see Color Functions), as in Visual Basic
.index PrefsLoad
.index PrefsCreate
.index PrefsSaveAs
+.index PrefsSetSecret
+.index PrefsGetSecret$
.index Settings Files
.index DlgFileOpenIn$
.index DlgFileSaveIn$
+.index DlgFolder$
.index FileOpsMakePath
.index FileOpsReplaceFile
.index FileOpsValidName
@@ -5055,6 +5058,24 @@ Colors in the form RGB makes (&HBBGGRR, see Color Functions), as in Visual Basic
.index ProjNew
.index ProjGet$
.index Project files
+.index pkg.bas
+.index Add-on packages
+.index PkgOpen
+.index PkgPlan
+.index PkgInstallStart
+.index PkgRunFor
+.index PkgRemoveStart
+.index PkgBuild
+.index PkgSign
+.index PkgKeygen
+.index json.bas
+.index http.bas
+.index pkgnet.bas
+.index JSON
+.index Web requests
+.index HttpOpen
+.index HttpRunFor
+.index JsonParse
.h1 Include Libraries
@@ -5063,7 +5084,7 @@ DVX ships a set of BASIC include files (.bas) in the sdk/include/basic directory
.table
File Provides
---- --------
- commdlg.bas File Open/Save dialogs (DlgFileOpenIn$ and DlgFileSaveIn$ start in a folder you name), input dialogs, choice dialogs, save-prompt dialogs, the color dialog, the font dialog (DlgFont), message boxes with your own buttons (DlgButtons)
+ commdlg.bas File Open/Save dialogs (DlgFileOpenIn$ and DlgFileSaveIn$ start in a folder you name), the folder dialog (DlgFolder$, for a Browse button), input dialogs, choice dialogs, save-prompt dialogs, the color dialog, the font dialog (DlgFont), message boxes with your own buttons (DlgButtons)
sql.bas SQLite database access (SQLOpen, SQLQuery, SQLNext, SQLField$, etc.)
comm.bas Serial-port I/O (raw and packet) and optional encrypted links; port settings read back and changed, why a call failed, breaks, ring, and line errors (SerOpen, SerSet, SerLastError, CommOpen, CommHandshakeStep, ...)
conn.bas Connections the way Terminal makes them: a serial port, a modem that dials, the secure link to the DVX host proxy, or Telnet, shown on a Terminal control, with capture, answerback, and file transfers (ConnCreate, ConnOpen, ConnPoll, ConnAttach, ConnSendFiles, ...)
@@ -5089,6 +5110,10 @@ DVX ships a set of BASIC include files (.bas) in the sdk/include/basic directory
debug.bas Run a compiled program inside yours and debug it: breakpoints, stepping, its variables, expressions, Immediate-window lines, its PRINT output (RunCreate, RunStart, RunPoll, DbgSetBreakpoints, DbgStepOver, DbgLocals$, DbgEval$, DbgExec$, ...)
designer.bas Form designers: the control types, whether each holds other controls and can be placed, the names of new controls, default events, toolbox tooltips and icons, and each type's properties (with their types and value names), methods, and events (with each event's parameters); the names in .frm and class module text; .frm files as data to read, edit, and write, controls copied and pasted as text, and a loaded form written back as .frm text (CtlTypeCount, CtlTypeName$, CtlTypeFlags, CtlPropCount, CtlPropName$, CtlPropType, CtlEventList$, CtlEventParams$, CtlTypeEventParams$, FrmFormName$, FrmControlNames$, ClsClassName$, FrmOpen, FrmText$, FrmCtlAdd, FrmCtlText$, FrmCtlPaste, FrmMenuAdd, FormText$, ...)
shell.bas DVX itself, everything Program Manager and Task Manager do: start programs and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...)
+ pkg.bas Add-on packages: the packages installed in DVX and their details, files, and needs; reading .DPK files and planning what installing them takes; installing and removing as a job with progress; trusting a publisher's key; and making packages, signing them, and making keys (PkgOpen, PkgInstalledRows$, PkgOfferFile, PkgPlan, PkgInstallStart, PkgRunFor, PkgRemoveStart, PkgBuild, PkgSign, PkgKeygen, ...)
+ json.bas JSON: read a document (a web service's reply, a settings file) and find values in it by path such as packages[3].version, and quote text for JSON of your own (JsonParse, JsonText$, JsonNumber, JsonCount, JsonKey$, JsonQuote$, ...)
+ http.bas Web requests: GET a page, a file, or a web service's answer, or POST to one, over the network a little at a time with progress, to memory or to a file (resuming one left part-way), following redirects (HttpOpen, HttpStart, HttpRunFor, HttpStatus, HttpBody$, HttpSaveTo, ...)
+ pkgnet.bas A package server: what it offers, what it has that is newer than what is installed, and fetching packages with what they need, for pkg.bas to install (PkgNetOpen, PkgNetCatalogue, PkgNetUpdates, PkgNetFetch, PkgNetRunFor, PkgNetRows$, ...)
.endtable
.h2 Using an Include Library
@@ -5349,7 +5374,7 @@ Files: FileOpsMakeDir and FileOpsRename (FileOpsError$ says why one failed), Fil
Drives: FileOpsDriveList$ (the drive letters), FileOpsDriveType (FILEOPS_DRIVE_FIXED for a hard disk, FILEOPS_DRIVE_REMOVABLE for a floppy disk drive, FILEOPS_DRIVE_REMOTE for a network drive or a CD-ROM; asking never makes a drive spin up), FileOpsBootDrive$ (the drive the computer started from), FileOpsFreeBytes, FileOpsFreeKb, and FileOpsTotalBytes (the size of the disk; right for disks past 2 GB too), FileOpsLongNames(path) (True when the disk holding path takes long file names: up to 255 characters, with spaces and several dots; otherwise names are 8.3). FileOpsIsWritable(folder) is False where files cannot be made (a CD-ROM, a write-protected floppy), and FileOpsReadOnlySystem() is True when nothing can be saved: DVX runs from such a disk and there is no TEMP folder to keep the user folder in. FileOpsTempDir$() is the folder for scratch files ("" when there is none).
-DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, and desktop pictures come in two folders each, DVX's own in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, and FILEOPS_KIND_WPAPER. FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
+DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, and MIDI instrument banks come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, and FILEOPS_KIND_BANKS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
FileOpsCopyTree, FileOpsMoveTree, and FileOpsDeleteTree do a whole folder at once. For a long copy, move, or delete the program stays usable when it runs the work as a job, a little at a time from a Timer: FileOpsJobCreate, then FileOpsJobAddSource for each file or folder, then FileOpsJobRunFor from the timer until it returns FILEOPS_STEP_DONE or FILEOPS_STEP_CANCELLED. When it returns FILEOPS_STEP_ASK (a file already exists, a file is read-only) or FILEOPS_STEP_ERROR, show FileOpsJobMessage$ and pass the user's choice to FileOpsJobAnswer. FileOpsJobCurrent$, FileOpsJobFilesDone, FileOpsJobFilesTotal, FileOpsJobPercent, FileOpsJobBytesDone, and FileOpsJobBytesTotal show the progress, and FileOpsJobCancel stops the job. Files copied from a CD-ROM are read-only; FileOpsJobClearAttr job, FILEOPS_ATTR_READONLY before the job runs makes every copy changeable (FILEOPS_ATTR_EDITABLE clears all four attribute bits). Whatever a program leaves open is freed when it ends.
@@ -5462,6 +5487,81 @@ Sub Timer1_Timer ()
End Sub
.endcode
+.h2 Add-on Packages
+
+With pkg.bas in the project, a program installs, removes, and makes add-on packages -- the .DPK files Software installs -- the way Software and Package Builder (both written in BASIC) do. PkgOpen() starts a session (a number; PkgClose ends it), and PkgError$(session) says why the last call that failed did. Lists come back as rows for a ListView's SetRows: cells separated by TAB, each row ending in a line feed.
+
+What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it.
+
+Installing. PkgOfferFile(s, path) reads a package file and every package beside it (what it may need) as offers, numbered from 0, and returns the number of the one at path (-1 when it cannot be read). PkgOfferField$ and PkgOfferFilesRows$ describe an offer before it is installed; PKG_FIELD_TRUST says whether DVX trusts who signed it. PkgPlan(s, "0") plans installing it with everything it needs, checked against this DVX and the packages installed (False, with PkgError$ saying why, when it cannot be installed); PkgPlanRows$ lists what will go in, PkgPlanBytes the disk space, and PkgPlanRestart whether libraries or widgets are among them (they work from the next time DVX starts). PkgInstallStart(s) then starts the job, and PkgRunFor(s, milliseconds) runs it a little at a time, returning PKG_RUN_BUSY until it is PKG_RUN_DONE or PKG_RUN_FAILED (PkgRunMessage$ says why); PkgRunPercent and PkgRunCurrent$ show how far it is.
+
+.code
+offer = PkgOfferFile(s, path)
+If offer >= 0 And PkgPlan(s, CStr(offer)) Then
+ If PkgInstallStart(s) Then
+ Do
+ rc = PkgRunFor(s, 60)
+ pbJob.Value = PkgRunPercent(s)
+ DoEvents
+ Loop While rc = PKG_RUN_BUSY
+ End If
+End If
+.endcode
+
+Removing. PkgDependents$(s, ids) lists the installed packages that need the ones going (they must go too), and PkgOrphans$(s, ids) those only they needed. PkgRemoveStart(s, ids, removeChanged) starts removing, run with PkgRunFor like an install; files changed since they were installed stay unless removeChanged, and PkgRunKept$ lists them.
+
+Trust. A publisher signs a package with a key so people can tell it came from them. PkgTrustKey(s, keyPath) trusts a publisher's key file (.KEY) from then on.
+
+From a package server. With pkgnet.bas in the project too, a program gets packages from a package server the way Software does. PkgNetOpen("") returns a handle on the server DVX's settings name, with the account they give; PkgNetClose frees it. PkgNetServer$, PkgNetPort, PkgNetUser$, and PkgNetPassword$ read those settings (PkgNetUrl$ is the address they make), and PkgNetSaveSettings(server, port, user, password) changes them. A job is started with PkgNetCatalogue (every package the server offers), PkgNetUpdates (what it has that is newer than the packages installed, DVX itself among them), or PkgNetFetch(n, ids) (packages by id, with everything they need that is not installed), and run with PkgNetRunFor(n, milliseconds) until it is PKGNET_DONE or PKGNET_FAILED (PkgNetError$ says why); PkgNetPercent and PkgNetCurrent$ show how far a fetch is. PkgNetRows$ lists the answer (id, name, version, kind, summary, size, and the version installed, if any), and PkgNetField$ and PkgNetDependsRows$ describe one package in it. Each package fetched is checked against the size and fingerprint the server gives, and the server's signature of it comes with it, so it is trusted or not exactly as a package file is. What was fetched is in PkgNetDir$: PkgOfferFolder(s, folder) reads every package there as the offers, and PkgOfferIndex(s, id) gives the number of the one wanted, to plan and install as above; PkgNetClear removes them afterwards. With an account (the settings', or PkgNetAccount(n, email, password) for another server), PkgNetUpload(n, path) publishes a package file for the server's review, and PkgNetMine(n) asks what the account has sent: PkgNetMineRows$ lists it (id, name, version, state, and the review's note); both sign in first.
+
+.code
+n = PkgNetOpen("")
+If PkgNetFetch(n, "acme.chess") Then
+ Do
+ rc = PkgNetRunFor(n, 60)
+ pbJob.Value = PkgNetPercent(n)
+ DoEvents
+ Loop While rc = PKGNET_BUSY
+End If
+If rc = PKGNET_DONE And PkgOfferFolder(s, PkgNetDir$(n)) > 0 Then
+ offer = PkgOfferIndex(s, "acme.chess")
+ ' plan and install offer as above, then PkgNetClear n
+End If
+.endcode
+
+Making packages. PkgBuild(s, template, files, out, secret) builds a package: template is a settings file with a [package] section (id, name, version, kind, and summary, publisher, author, licence, homepage, dvx as wanted) and a [depends] section (id = oldest version for each package it needs); files are rows of a name in the package (from the DVX folder, such as APPS\ACME\CHESS\CHESS.APP) and the file it is made from; secret is the secret key file (.SEC) to sign with, or "". PkgFolderRows$(s, folder, prefix) gives those rows for every file in a folder, and PkgKindFolder$(kind, id) the folder a package of that kind keeps its files in. PkgSign signs a package already built, PkgKeygen(s, name, base) makes a key pair (base.KEY to give out, base.SEC to keep private), and PkgKeyInfo$ reads a key file's name and id. PkgIdValid and PkgVersionValid check an id and a version before building; PkgKindName$ lists the kinds.
+
+.h2 Web Requests and JSON
+
+With http.bas in the project, a program asks web servers for pages, files, and web services' answers. HttpOpen(method, url) makes a request ("GET" to fetch, "POST" to send) and returns a handle; HttpClose frees it. Before starting it, HttpAddHeader adds a header, HttpSetBody gives a body to send with its type ("application/json", say), HttpSaveTo writes the answer's body to a file instead of keeping it (with keepPart True, a file left part-way is finished rather than started again), and HttpSetTimeout sets how many seconds without progress mean the server is not answering (30 to start with). HttpStart starts it (False for an address that is not http://; there is no encryption), and HttpRunFor(h, milliseconds) runs it a little at a time, returning HTTP_BUSY until it is HTTP_DONE or HTTP_FAILED:
+
+.code
+h = HttpOpen("GET", "http://example.com/news.txt")
+IF HttpStart(h) THEN
+ DO
+ rc = HttpRunFor(h, 60)
+ lblProgress.Caption = CStr(HttpBytes(h)) & " bytes"
+ DoEvents
+ LOOP WHILE rc = HTTP_BUSY
+END IF
+IF rc = HTTP_DONE AND HttpStatus(h) = 200 THEN txtNews.Text = HttpBody$(h)
+IF rc = HTTP_FAILED THEN MsgBox HttpError$(h)
+HttpClose h
+.endcode
+
+HTTP_DONE means the server answered: HttpStatus is how (200 OK, 404 not found, ...), HttpBody$ what it sent, HttpReplyHeader$ one of its headers, and HttpUrl$ where the answer came from when the server sent the request elsewhere (redirects are followed). HTTP_FAILED means no answer came, and HttpError$ says why. HttpBytes and HttpTotal (-1 until the server says) show progress.
+
+With json.bas in the project, JsonParse(text) reads JSON -- what most web services answer with -- and returns a handle (0 when it is not JSON: JsonError$ says why and on which line); JsonFree frees it. The other functions find a value by its path from the top: member names and item numbers from 0, such as "packages[3].version". JsonType says what is there (JSON_STRING, JSON_NUMBER, JSON_ARRAY, ..., JSON_NONE for nothing), JsonText$ gives a string, JsonNumber a number, JsonBool True or False, JsonCount how many items an array or members an object has, and JsonKey$(h, path, i) the name of an object's member i. JsonQuote$(text) puts text in quotes with its special characters escaped, for building JSON to send:
+
+.code
+h = JsonParse(HttpBody$(req))
+FOR i = 0 TO JsonCount(h, "packages") - 1
+ lstPackages.AddItem JsonText$(h, "packages[" & CStr(i) & "].name")
+NEXT i
+JsonFree h
+body = "{" & JsonQuote$("name") & ": " & JsonQuote$(txtName.Text) & "}"
+.endcode
+
.h2 Changing DVX's Settings
With system.bas in the project, a program changes what Control Panel changes, at once for the whole desktop. Add prefs.bas too: the settings are saved through a prefs.bas handle.
@@ -5996,6 +6096,8 @@ Handles a program leaves open are freed, and their keys wiped, when it ends.
With prefs.bas in the project, a program keeps its settings in an INI file the way DVX's own programs do. PrefsLoad(path) reads the file once (a file that is not there reads as empty); PrefsGet$, PrefsGetInt, and PrefsGetBool read a key of a section (each with the value to use when it is missing), and PrefsSet, PrefsSetInt, PrefsSetBool, and PrefsRemove change it in memory. PrefsSave writes it back, keeping comments and every line it did not change. To write a whole new file -- dropping sections that are no longer wanted -- fill a PrefsCreate handle and save it with PrefsSaveAs: the old file is replaced only once the new one is completely written. PrefsClose frees a handle. IniRead and IniWrite (built in) do one value at a time and read or write the whole file each time; prefs.bas is the faster choice for many settings.
+A password is better kept with PrefsSetSecret handle, section, key, value and read back with PrefsGetSecret$(handle, section, key) ("" when it is missing). The file then holds a "*" and a row of letters and digits that change each time it is saved, not the password itself, so it cannot be read over someone's shoulder. This scrambles the password; it does not lock it away: anyone who knows how DVX scrambles it can unscramble it, so treat the file with the same care as the password. A value typed into the file by hand, without the "*", is read as it is.
+
.code
Dim ini As Long
ini = PrefsLoad(App.User & "\MYAPP.INI")
diff --git a/src/apps/kpunch/dvxbasic/runtime/vm.c b/src/apps/kpunch/dvxbasic/runtime/vm.c
index 95155807..9814e9d9 100644
--- a/src/apps/kpunch/dvxbasic/runtime/vm.c
+++ b/src/apps/kpunch/dvxbasic/runtime/vm.c
@@ -35,6 +35,7 @@
#include "dvxPlat.h"
#include "dvxPrefs.h"
#include "screen.h"
+#include "dvxBas.h"
#include
#include
@@ -237,9 +238,6 @@
#define BAS_ARRAY_SUFFIX "()"
#define BAS_ARRAY_SUFFIX_LEN 3
-// BASIC's True as a bit pattern (every bit set).
-#define BAS_TRUE_BITS (-1)
-
// The fast loop (runFast): one handler per opcode byte value; an array or
// TYPE instance it reads or writes through must be held elsewhere too (its
// variable), so dropping the stack's count frees nothing.
@@ -10674,13 +10672,13 @@ static BasVmResultE logicalNull(BasVmT *vm, uint8_t op, BasValueT a, BasValueT b
bool isLong = given->type == BAS_TYPE_LONG;
// True OR Null, False IMP Null and Null IMP True are all True
- bool orTrue = op == OP_OR && other == BAS_TRUE_BITS;
- bool impTrue = op == OP_IMP && ((bNull && other == 0) || (aNull && other == BAS_TRUE_BITS));
+ bool orTrue = op == OP_OR && other == BAS_TRUE;
+ bool impTrue = op == OP_IMP && ((bNull && other == 0) || (aNull && other == BAS_TRUE));
if (known && op == OP_AND && other == 0) {
result = logicalResult(0, isBool, isLong);
} else if (known && (orTrue || impTrue)) {
- result = logicalResult(BAS_TRUE_BITS, isBool, isLong);
+ result = logicalResult(BAS_TRUE, isBool, isLong);
}
basValRelease(&a);
@@ -10705,7 +10703,7 @@ static BasValueT logicalResult(int32_t r, bool isBool, bool isLong) {
static inline void logicalSet(BasValueT *v, int32_t r, bool isBool, bool isLong) {
if (isBool) {
v->type = BAS_TYPE_BOOLEAN;
- v->boolVal = r ? BAS_TRUE_BITS : 0;
+ v->boolVal = r ? BAS_TRUE : 0;
} else {
fastSetInt(v, r, isLong);
}
@@ -12888,7 +12886,7 @@ fastPushBool:
}
sp->type = BAS_TYPE_BOOLEAN;
- sp->boolVal = (op == OP_PUSH_TRUE) ? BAS_TRUE_BITS : 0;
+ sp->boolVal = (op == OP_PUSH_TRUE) ? BAS_TRUE : 0;
sp++;
ip++;
FAST_NEXT();
@@ -13374,7 +13372,7 @@ fastCompare: {
}
a->type = BAS_TYPE_BOOLEAN;
- a->boolVal = result ? BAS_TRUE_BITS : 0;
+ a->boolVal = result ? BAS_TRUE : 0;
FAST_BINARY_DONE();
}
diff --git a/src/apps/kpunch/fileMgr/fileMgr.dhs b/src/apps/kpunch/fileMgr/fileMgr.dhs
index 75635685..73da0d4a 100644
--- a/src/apps/kpunch/fileMgr/fileMgr.dhs
+++ b/src/apps/kpunch/fileMgr/fileMgr.dhs
@@ -144,7 +144,7 @@ If nothing is selected, commands work on the highlighted item. When the folder t
There are two ways to copy or move files and folders:
.list
-.item Select them and choose File > Copy To... (F8) or File > Move To... (F7), then type the folder they should go to.
+.item Select them and choose File > Copy To... (F8) or File > Move To... (F7), then choose the folder they should go to. To put them in a new folder, type its name in the Folder name box; it is made for you.
.item Select them and choose Edit > Copy (Ctrl+C) or Edit > Cut (Ctrl+X). Then, in this or another File Manager window, go to the folder they should go to and choose Edit > Paste (Ctrl+V). Copy leaves the originals where they are; Cut moves them. Files you copied can be pasted again in other folders; after pasting a cut, the clipboard is empty.
.endlist
@@ -262,8 +262,8 @@ The Attributes boxes can be changed; click OK to apply them to every selected it
---- ------- ------------
File Open Open the highlighted item.
File New Folder Make a folder.
- File Copy To Copy the selection to a folder you type.
- File Move To Move the selection to a folder you type.
+ File Copy To Copy the selection to a folder you choose.
+ File Move To Move the selection to a folder you choose.
File Rename Rename the selected item.
File Delete Delete the selection.
File Properties Size, date, and attributes.
diff --git a/src/apps/kpunch/fileMgr/fileMgr.frm b/src/apps/kpunch/fileMgr/fileMgr.frm
index d94fbd51..da070ccc 100644
--- a/src/apps/kpunch/fileMgr/fileMgr.frm
+++ b/src/apps/kpunch/fileMgr/fileMgr.frm
@@ -1335,12 +1335,12 @@ SUB CmdSort(BYVAL col AS LONG, BYVAL descending AS BOOLEAN)
END SUB
-' Copy To / Move To (FILEOPS_COPY / FILEOPS_MOVE): ask for the
-' destination folder.
+' Copy To / Move To (FILEOPS_COPY / FILEOPS_MOVE): the destination folder
+' from the folder dialog (one typed there that is not made yet is made).
SUB CmdTransferTo(BYVAL op AS LONG)
DIM paths AS Collection
DIM verb AS STRING
- DIM prompt AS STRING
+ DIM title AS STRING
DIM dest AS STRING
DIM norm AS STRING
@@ -1357,23 +1357,29 @@ SUB CmdTransferTo(BYVAL op AS LONG)
END IF
IF paths.Count = 1 THEN
- prompt = verb & " " & FileOpsBaseName$(paths(1)) & " to folder:"
+ title = verb & " " & FileOpsBaseName$(paths(1)) & " To"
ELSE
- prompt = verb & " " & CStr(paths.Count) & " items to folder:"
+ title = verb & " " & CStr(paths.Count) & " Items To"
END IF
- dest = DlgInputBox$(verb, prompt, curFolder)
+ dest = DlgFolder$(title, curFolder)
- IF DlgInputCancelled() THEN
+ IF dest = "" THEN
EXIT SUB
END IF
norm = FileOpsPathNormalize$(dest)
+ IF norm = "" THEN
+ IF FileOpsMakePath(dest) THEN
+ norm = FileOpsPathNormalize$(dest)
+ END IF
+ END IF
+
IF norm <> "" THEN
JobStart op, paths, norm
ELSE
- ShowError verb, dest & " is not an existing folder, or its drive is not ready."
+ ShowError verb, "The folder " & dest & " cannot be made, or its drive is not ready."
END IF
END SUB
diff --git a/src/apps/kpunch/pkgbuild/ICON32.BMP b/src/apps/kpunch/pkgbuild/ICON32.BMP
new file mode 100644
index 00000000..718c1d4b
--- /dev/null
+++ b/src/apps/kpunch/pkgbuild/ICON32.BMP
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efceae46e526fec7c857bcd05c642639d07daeca3d7eb97a0f2e8ad2db0eb597
+size 3126
diff --git a/src/apps/kpunch/pkgbuild/pkgbuild.bas b/src/apps/kpunch/pkgbuild/pkgbuild.bas
new file mode 100644
index 00000000..b377cb60
--- /dev/null
+++ b/src/apps/kpunch/pkgbuild/pkgbuild.bas
@@ -0,0 +1,589 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' pkgbuild.bas -- Package Builder: what its window shares.
+'
+' A package is described on five pages -- the package itself, who made
+' it, what it needs, its files, and the key it is signed with -- and
+' kept as a template: a PACKAGE.INI without its format and [files] (the
+' file dvxpkg build takes too), plus a [builder] section with the folder
+' of files, how that folder is laid out, and the key. Building writes the template beside
+' the new package for a moment and hands it, the files, and the key to
+' PkgBuild.
+
+OPTION EXPLICIT
+
+CONST PB_TITLE = "Package Builder"
+CONST PB_TAB = CHR$(9) ' between the cells of a pkg.bas row
+CONST PB_EOL = CHR$(10) ' after each row
+CONST PB_FILTER = "Package Templates (*.ini)|All Files (*.*)"
+CONST PB_DPK_FILTER = "Packages (*.dpk)|All Files (*.*)"
+CONST PB_SEC_FILTER = "Secret Keys (*.sec)|All Files (*.*)"
+CONST PB_SEC_PATTERN = "*.sec"
+CONST PB_KEY_EXT = ".KEY"
+CONST PB_SEC_EXT = ".SEC"
+CONST PB_KEYS_DIR = "KEYS" ' in the user folder: the keys DVX trusts, and your own
+CONST PB_SYSTEM_KIND = "system" ' DVX itself: not one to make
+CONST PB_FIRST_VERSION = "1.0.0"
+CONST PB_NOT_SIGNED = "Not signed"
+CONST PB_PAGE_PACKAGE = 0 ' the tabs: the Package page
+CONST PB_PAGE_FILES = 3 ' the Files page
+
+' The template: the manifest's sections and keys, and the builder's own
+CONST PB_SECT_PACKAGE = "package"
+CONST PB_SECT_DEPENDS = "depends"
+CONST PB_SECT_BUILDER = "builder"
+CONST PB_KEY_FOLDER = "folder"
+CONST PB_KEY_LAYOUT = "layout"
+CONST PB_KEY_SIGN = "key"
+CONST PB_LAYOUT_OWN = "own" ' the folder holds the package's own files
+CONST PB_LAYOUT_DVX = "dvx" ' the folder is laid out like the DVX folder
+
+' The lists: what the package needs (package, name, oldest version), and
+' its files (the name in the package, where it is now)
+CONST PB_NEED_COLUMNS = "Package,16|Name,18|Oldest version,12"
+CONST PB_FILE_COLUMNS = "In the package,30|From,30"
+
+' The session (PkgOpen), open while the program runs: the installed
+' packages to need, and why a call failed
+DIM SHARED pbSession AS LONG
+
+' The template's file ("" until it is saved)
+DIM SHARED pbPath AS STRING
+
+' What the package needs, as the list's rows
+DIM SHARED pbNeeds AS STRING
+
+' The key list's files, one per line, in its order ("" first: not signed)
+DIM SHARED pbKeys AS STRING
+
+' PbState$ when the template was last opened or saved
+DIM SHARED pbSaved AS STRING
+
+
+' Build the package: checked first, then saved where the user says, from
+' the template, the files, and the key.
+SUB PbBuild
+ DIM outPath AS STRING
+ DIM tmplPath AS STRING
+ DIM rows AS STRING
+ DIM ok AS BOOLEAN
+
+ IF NOT PbCheck() THEN
+ EXIT SUB
+ END IF
+
+ rows = PbFileRows$()
+
+ IF rows = "" AND PkgError$(pbSession) <> "" THEN
+ PbError "The files cannot be read. " & PkgError$(pbSession)
+ EXIT SUB
+ END IF
+
+ outPath = DlgFileSave$("Build Package", PB_DPK_FILTER)
+
+ IF outPath = "" THEN
+ EXIT SUB
+ END IF
+
+ ' The template beside the package for the moment it takes
+ tmplPath = FileOpsTempPath$(outPath)
+
+ IF NOT PbWrite(tmplPath) THEN
+ PbError "The package cannot be built: " & tmplPath & " cannot be written."
+ EXIT SUB
+ END IF
+
+ ok = PkgBuild(pbSession, tmplPath, rows, outPath, PbKeyPath$())
+ KILL tmplPath
+
+ IF NOT ok THEN
+ PbError "The package cannot be built. " & PkgError$(pbSession)
+ EXIT SUB
+ END IF
+
+ BuilderForm.lblStatus.Caption = "Built " & outPath
+ ShellLog "Package Builder: built " & outPath
+ MsgBox "The package is built:" & PB_EOL & PB_EOL & outPath, vbOKOnly + vbInformation, PB_TITLE
+END SUB
+
+
+' What a package must have before it is built; False (and a word why)
+' when something is missing or wrong.
+FUNCTION PbCheck() AS BOOLEAN
+ DIM problem AS STRING
+
+ IF Trim$(BuilderForm.txtName.Text) = "" THEN
+ problem = "Give the package a name."
+ ELSEIF NOT PkgIdValid(PbId$()) THEN
+ problem = "The package name must be two parts with a dot between them, such as acme.chess: who made it, then what it is, each 1 to 8 lower-case letters, digits, or _."
+ ELSEIF NOT PkgVersionValid(Trim$(BuilderForm.txtVersion.Text)) THEN
+ problem = "The version must be numbers with dots between them, such as 1.0.0."
+ ELSEIF Trim$(BuilderForm.txtDvx.Text) <> "" AND NOT PkgVersionValid(Trim$(BuilderForm.txtDvx.Text)) THEN
+ problem = "The oldest DVX must be a version such as " & PkgDvxVersion$() & ", or left empty."
+ END IF
+
+ IF problem <> "" THEN
+ BuilderForm.tabs.SetActive PB_PAGE_PACKAGE
+ PbError problem
+ EXIT FUNCTION
+ END IF
+
+ PbCheck = True
+END FUNCTION
+
+
+SUB PbError(BYVAL message AS STRING)
+ MsgBox message, vbOKOnly + vbCritical, PB_TITLE
+END SUB
+
+
+' The package's files: rows of each one's name in the package and where
+' it is now ("" when there is no folder, or it cannot be read: PkgError$
+' says).
+FUNCTION PbFileRows$() AS STRING
+ DIM folder AS STRING
+
+ folder = Trim$(BuilderForm.txtFolder.Text)
+
+ IF folder = "" THEN
+ EXIT FUNCTION
+ END IF
+
+ IF BuilderForm.optDvx.Value THEN
+ PbFileRows$ = PkgFolderRows$(pbSession, folder, "")
+ ELSE
+ PbFileRows$ = PkgFolderRows$(pbSession, folder, PkgKindFolder$(PbKind$(), PbId$()))
+ END IF
+END FUNCTION
+
+
+' Show the files and where they go.
+SUB PbFilesShow
+ DIM goesIn AS STRING
+ DIM rows AS STRING
+
+ goesIn = PkgKindFolder$(PbKind$(), PbId$())
+
+ IF BuilderForm.optDvx.Value THEN
+ BuilderForm.lblGoesIn.Caption = "Each file goes in the same place in the DVX folder."
+ ELSEIF goesIn <> "" THEN
+ BuilderForm.lblGoesIn.Caption = "The files go in " & goesIn & " in the DVX folder."
+ ELSEIF NOT PkgIdValid(PbId$()) THEN
+ BuilderForm.lblGoesIn.Caption = "Give the package its package name on the Package page first."
+ ELSE
+ BuilderForm.lblGoesIn.Caption = "This kind of package has no files of its own."
+ END IF
+
+ rows = PbFileRows$()
+ BuilderForm.lvFiles.SetRows rows
+END SUB
+
+
+' The package name as the template holds it.
+FUNCTION PbId$() AS STRING
+ PbId$ = LCase$(Trim$(BuilderForm.txtId.Text))
+END FUNCTION
+
+
+' Add the secret key file path to the key list (its name, and its file's);
+' False when it is no key.
+FUNCTION PbKeyAdd(BYVAL path AS STRING) AS BOOLEAN
+ DIM info AS STRING
+
+ info = PkgKeyInfo$(pbSession, path)
+
+ IF info = "" THEN
+ EXIT FUNCTION
+ END IF
+
+ BuilderForm.ddKey.AddItem Split(info, PB_TAB)(0) & " (" & FileOpsBaseName$(path) & ")"
+ pbKeys = pbKeys & path & PB_EOL
+ PbKeyAdd = True
+END FUNCTION
+
+
+' The path of the key chosen, or "" (not signed).
+FUNCTION PbKeyPath$() AS STRING
+ IF BuilderForm.ddKey.ListIndex > 0 THEN
+ PbKeyPath$ = Split(pbKeys, PB_EOL)(BuilderForm.ddKey.ListIndex)
+ END IF
+END FUNCTION
+
+
+' Fill the key list: not signed, the secret keys in the user's KEYS
+' folder, and choose (a key file elsewhere) when it is none of them; then
+' choose it ("": not signed).
+SUB PbKeysFill(BYVAL choose AS STRING)
+ DIM folder AS STRING
+ DIM list AS LONG
+ DIM i AS LONG
+ DIM path AS STRING
+ DIM chosen AS LONG
+ DIM found AS BOOLEAN
+
+ BuilderForm.ddKey.Clear
+ BuilderForm.ddKey.AddItem PB_NOT_SIGNED
+ pbKeys = PB_EOL
+ folder = FileOpsUserPath$(PB_KEYS_DIR)
+ list = FileOpsList(folder, PB_SEC_PATTERN)
+
+ FOR i = 0 TO FileOpsListCount(list) - 1
+ IF NOT FileOpsListIsFolder(list, i) THEN
+ path = FileOpsPathJoin$(folder, FileOpsListName$(list, i))
+
+ IF PbKeyAdd(path) AND choose <> "" AND FileOpsPathSame(path, choose) THEN
+ chosen = BuilderForm.ddKey.ListCount() - 1
+ found = True
+ END IF
+ END IF
+ NEXT i
+
+ FileOpsListFree list
+
+ IF choose <> "" AND NOT found THEN
+ IF PbKeyAdd(choose) THEN
+ chosen = BuilderForm.ddKey.ListCount() - 1
+ END IF
+ END IF
+
+ BuilderForm.ddKey.ListIndex = chosen
+END SUB
+
+
+' The kind chosen ("app", ...).
+FUNCTION PbKind$() AS STRING
+ IF BuilderForm.ddKind.ListIndex >= 0 THEN
+ PbKind$ = BuilderForm.ddKind.List(BuilderForm.ddKind.ListIndex)
+ END IF
+END FUNCTION
+
+
+' Choose the kind kindName in the kind list (the first when it is none).
+SUB PbKindChoose(BYVAL kindName AS STRING)
+ DIM i AS LONG
+
+ BuilderForm.ddKind.ListIndex = 0
+
+ FOR i = 0 TO BuilderForm.ddKind.ListCount() - 1
+ IF BuilderForm.ddKind.List(i) = LCase$(kindName) THEN
+ BuilderForm.ddKind.ListIndex = i
+ END IF
+ NEXT i
+END SUB
+
+
+' Open the template path into the window; False when it cannot be read.
+FUNCTION PbLoad(BYVAL path AS STRING) AS BOOLEAN
+ DIM h AS LONG
+ DIM i AS LONG
+ DIM id AS STRING
+
+ h = PrefsLoad(path)
+
+ IF h = 0 THEN
+ EXIT FUNCTION
+ END IF
+
+ BuilderForm.txtName.Text = PrefsGet$(h, PB_SECT_PACKAGE, "name", "")
+ BuilderForm.txtId.Text = PrefsGet$(h, PB_SECT_PACKAGE, "id", "")
+ BuilderForm.txtVersion.Text = PrefsGet$(h, PB_SECT_PACKAGE, "version", PB_FIRST_VERSION)
+ BuilderForm.txtSummary.Text = PrefsGet$(h, PB_SECT_PACKAGE, "summary", "")
+ BuilderForm.txtPublisher.Text = PrefsGet$(h, PB_SECT_PACKAGE, "publisher", "")
+ BuilderForm.txtAuthor.Text = PrefsGet$(h, PB_SECT_PACKAGE, "author", "")
+ BuilderForm.txtLicence.Text = PrefsGet$(h, PB_SECT_PACKAGE, "licence", "")
+ BuilderForm.txtHomepage.Text = PrefsGet$(h, PB_SECT_PACKAGE, "homepage", "")
+ BuilderForm.txtDvx.Text = PrefsGet$(h, PB_SECT_PACKAGE, "dvx", "")
+ PbKindChoose PrefsGet$(h, PB_SECT_PACKAGE, "kind", "")
+
+ pbNeeds = ""
+ id = PrefsKeyName$(h, PB_SECT_DEPENDS, 0)
+
+ DO WHILE id <> ""
+ pbNeeds = pbNeeds & id & PB_TAB & PkgField$(pbSession, id, PKG_FIELD_NAME) & PB_TAB & PrefsGet$(h, PB_SECT_DEPENDS, id, "") & PB_EOL
+ i = i + 1
+ id = PrefsKeyName$(h, PB_SECT_DEPENDS, i)
+ LOOP
+
+ BuilderForm.lvNeeds.SetRows pbNeeds
+ BuilderForm.txtFolder.Text = PrefsGet$(h, PB_SECT_BUILDER, PB_KEY_FOLDER, "")
+
+ IF PrefsGet$(h, PB_SECT_BUILDER, PB_KEY_LAYOUT, PB_LAYOUT_OWN) = PB_LAYOUT_DVX THEN
+ BuilderForm.optDvx.Value = True
+ ELSE
+ BuilderForm.optOwn.Value = True
+ END IF
+
+ PbKeysFill PrefsGet$(h, PB_SECT_BUILDER, PB_KEY_SIGN, "")
+ PrefsClose h
+ PbFilesShow
+ PbLoad = True
+END FUNCTION
+
+
+' Add a package this one needs: one of the installed packages, and the
+' oldest version of it that will do.
+SUB PbNeedAdd
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM ids AS STRING
+ DIM items AS STRING
+ DIM pick AS LONG
+ DIM id AS STRING
+ DIM version AS STRING
+
+ FOR EACH row IN Split(PkgInstalledRows$(pbSession), PB_EOL)
+ IF row <> "" THEN
+ cells = Split(row, PB_TAB)
+
+ IF cells(3) <> PB_SYSTEM_KIND AND cells(0) <> PbId$() AND InStr(PB_EOL & pbNeeds, PB_EOL & cells(0) & PB_TAB) = 0 THEN
+ ids = ids & cells(0) & PB_EOL
+ items = items & IIF(items = "", "", "|") & cells(1) & " (" & cells(0) & ")"
+ END IF
+ END IF
+ NEXT row
+
+ IF items = "" THEN
+ MsgBox "There are no other installed packages to choose from: a package can need only packages installed in this DVX.", vbOKOnly + vbInformation, PB_TITLE
+ EXIT SUB
+ END IF
+
+ pick = DlgChoice("Add a Need", "Which package does this one need?", items, 0)
+
+ IF pick < 0 THEN
+ EXIT SUB
+ END IF
+
+ id = Split(ids, PB_EOL)(pick)
+ version = Trim$(DlgInputBox$("Oldest Version", "The oldest version of " & PkgField$(pbSession, id, PKG_FIELD_NAME) & " that will do:", PkgField$(pbSession, id, PKG_FIELD_VERSION)))
+
+ IF DlgInputCancelled() OR version = "" THEN
+ EXIT SUB
+ END IF
+
+ IF NOT PkgVersionValid(version) THEN
+ PbError version & " is not a version (such as 1.0.0)."
+ EXIT SUB
+ END IF
+
+ pbNeeds = pbNeeds & id & PB_TAB & PkgField$(pbSession, id, PKG_FIELD_NAME) & PB_TAB & version & PB_EOL
+ BuilderForm.lvNeeds.SetRows pbNeeds
+END SUB
+
+
+' Take the chosen need off the list.
+SUB PbNeedRemove
+ DIM row AS VARIANT
+ DIM rows AS STRING
+ DIM gone AS LONG
+ DIM i AS LONG
+
+ gone = BuilderForm.lvNeeds.ListIndex
+
+ IF gone < 0 THEN
+ MsgBox "Choose a package in the list first.", vbOKOnly + vbInformation, PB_TITLE
+ EXIT SUB
+ END IF
+
+ FOR EACH row IN Split(pbNeeds, PB_EOL)
+ IF row <> "" THEN
+ IF i <> gone THEN
+ rows = rows & row & PB_EOL
+ END IF
+
+ i = i + 1
+ END IF
+ NEXT row
+
+ pbNeeds = rows
+ BuilderForm.lvNeeds.SetRows pbNeeds
+END SUB
+
+
+' Make a key pair, saved where the user says (the user's KEYS folder to
+' start with, where DVX trusts it too), and choose it.
+SUB PbNewKey
+ DIM keyName AS STRING
+ DIM folder AS STRING
+ DIM path AS STRING
+ DIM ext AS STRING
+ DIM keyBase AS STRING
+
+ keyName = Trim$(DlgInputBox$("New Key", "The name people see on what you sign, such as yours or your company's:", Trim$(BuilderForm.txtPublisher.Text)))
+
+ IF DlgInputCancelled() OR keyName = "" THEN
+ EXIT SUB
+ END IF
+
+ folder = FileOpsUserPath$(PB_KEYS_DIR)
+
+ IF NOT FileOpsMakePath(folder) THEN
+ PbError "The folder " & folder & " cannot be made. " & FileOpsError$()
+ EXIT SUB
+ END IF
+
+ path = DlgFileSaveIn$("Save the New Key As", PB_SEC_FILTER, folder)
+
+ IF path = "" THEN
+ EXIT SUB
+ END IF
+
+ ext = FileOpsExtension$(path)
+ keyBase = IIF(ext = "", path, Left$(path, Len(path) - Len(ext) - 1))
+
+ IF NOT PkgKeygen(pbSession, keyName, keyBase) THEN
+ PbError "The key cannot be made. " & PkgError$(pbSession)
+ EXIT SUB
+ END IF
+
+ PbKeysFill keyBase & PB_SEC_EXT
+ MsgBox "Your key is made." & PB_EOL & PB_EOL & "Give " & FileOpsBaseName$(keyBase & PB_KEY_EXT) & " to the people who install your packages: it tells DVX that they are yours. Keep " & FileOpsBaseName$(keyBase & PB_SEC_EXT) & " private: anyone who has it can sign packages as you.", vbOKOnly + vbInformation, PB_TITLE
+END SUB
+
+
+' Start a new package: the window emptied (the key kept).
+SUB PbReset
+ BuilderForm.txtName.Text = ""
+ BuilderForm.txtId.Text = ""
+ BuilderForm.txtVersion.Text = PB_FIRST_VERSION
+ BuilderForm.txtSummary.Text = ""
+ BuilderForm.txtPublisher.Text = ""
+ BuilderForm.txtAuthor.Text = ""
+ BuilderForm.txtLicence.Text = ""
+ BuilderForm.txtHomepage.Text = ""
+ BuilderForm.txtDvx.Text = PkgDvxVersion$()
+ BuilderForm.txtFolder.Text = ""
+ BuilderForm.optOwn.Value = True
+ PbKindChoose ""
+ pbNeeds = ""
+ BuilderForm.lvNeeds.SetRows pbNeeds
+ PbFilesShow
+ pbPath = ""
+ pbSaved = PbState$()
+ PbShowTitle
+END SUB
+
+
+' Save the template: to pbPath, or where the user says when it has none
+' or asAs. False when it was not saved.
+FUNCTION PbSave(BYVAL asAs AS BOOLEAN) AS BOOLEAN
+ DIM path AS STRING
+
+ path = pbPath
+
+ IF asAs OR path = "" THEN
+ path = DlgFileSave$("Save Template", PB_FILTER)
+ END IF
+
+ IF path = "" THEN
+ EXIT FUNCTION
+ END IF
+
+ IF NOT PbWrite(path) THEN
+ PbError path & " cannot be written."
+ EXIT FUNCTION
+ END IF
+
+ pbPath = path
+ pbSaved = PbState$()
+ PbShowTitle
+ PbSave = True
+END FUNCTION
+
+
+' False when the user would rather keep the window as it is: asked first
+' when it changed since it was saved, and saved when they say so.
+FUNCTION PbSaveFirst() AS BOOLEAN
+ DIM answer AS LONG
+
+ IF PbState$() = pbSaved THEN
+ PbSaveFirst = True
+ EXIT FUNCTION
+ END IF
+
+ answer = MsgBox("Save the changes to this package's template?", vbYesNoCancel + vbQuestion, PB_TITLE)
+
+ IF answer = vbYes THEN
+ PbSaveFirst = PbSave(False)
+ ELSEIF answer = vbNo THEN
+ PbSaveFirst = True
+ END IF
+END FUNCTION
+
+
+' A key of [package], when it has a value.
+SUB PbSetIf(BYVAL h AS LONG, BYVAL key AS STRING, BYVAL value AS STRING)
+ IF Trim$(value) <> "" THEN
+ PrefsSet h, PB_SECT_PACKAGE, key, Trim$(value)
+ END IF
+END SUB
+
+
+' The window's caption and status: the template's file.
+SUB PbShowTitle
+ BuilderForm.lblStatus.Caption = IIF(pbPath = "", "New package", pbPath)
+END SUB
+
+
+' Everything the template holds, as one text: to tell whether it changed.
+FUNCTION PbState$() AS STRING
+ PbState$ = BuilderForm.txtName.Text & PB_TAB & BuilderForm.txtId.Text & PB_TAB & BuilderForm.txtVersion.Text & PB_TAB & PbKind$() & PB_TAB & BuilderForm.txtSummary.Text & PB_TAB & BuilderForm.txtPublisher.Text & PB_TAB & BuilderForm.txtAuthor.Text & PB_TAB & BuilderForm.txtLicence.Text & PB_TAB & BuilderForm.txtHomepage.Text & PB_TAB & BuilderForm.txtDvx.Text & PB_TAB & pbNeeds & PB_TAB & BuilderForm.txtFolder.Text & PB_TAB & CStr(BuilderForm.optDvx.Value) & PB_TAB & PbKeyPath$()
+END FUNCTION
+
+
+' Write the template to path; False when it cannot be written.
+FUNCTION PbWrite(BYVAL path AS STRING) AS BOOLEAN
+ DIM h AS LONG
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+
+ h = PrefsCreate()
+
+ IF h = 0 THEN
+ EXIT FUNCTION
+ END IF
+
+ PbSetIf h, "id", PbId$()
+ PbSetIf h, "name", BuilderForm.txtName.Text
+ PbSetIf h, "version", BuilderForm.txtVersion.Text
+ PbSetIf h, "kind", PbKind$()
+ PbSetIf h, "summary", BuilderForm.txtSummary.Text
+ PbSetIf h, "publisher", BuilderForm.txtPublisher.Text
+ PbSetIf h, "author", BuilderForm.txtAuthor.Text
+ PbSetIf h, "licence", BuilderForm.txtLicence.Text
+ PbSetIf h, "homepage", BuilderForm.txtHomepage.Text
+ PbSetIf h, "dvx", BuilderForm.txtDvx.Text
+
+ FOR EACH row IN Split(pbNeeds, PB_EOL)
+ IF row <> "" THEN
+ cells = Split(row, PB_TAB)
+ PrefsSet h, PB_SECT_DEPENDS, cells(0), cells(2)
+ END IF
+ NEXT row
+
+ PrefsSet h, PB_SECT_BUILDER, PB_KEY_FOLDER, Trim$(BuilderForm.txtFolder.Text)
+ PrefsSet h, PB_SECT_BUILDER, PB_KEY_LAYOUT, IIF(BuilderForm.optDvx.Value, PB_LAYOUT_DVX, PB_LAYOUT_OWN)
+ PrefsSet h, PB_SECT_BUILDER, PB_KEY_SIGN, PbKeyPath$()
+ PbWrite = PrefsSaveAs(h, path)
+ PrefsClose h
+END FUNCTION
diff --git a/src/apps/kpunch/pkgbuild/pkgbuild.dbp b/src/apps/kpunch/pkgbuild/pkgbuild.dbp
new file mode 100644
index 00000000..65d7e33f
--- /dev/null
+++ b/src/apps/kpunch/pkgbuild/pkgbuild.dbp
@@ -0,0 +1,25 @@
+[Project]
+Name = Package Builder
+Author = Scott Duensing
+Publisher = Kangaroo Punch Studios
+Version = 1.00
+Copyright = Copyright 2026 Scott Duensing
+Description = Make add-on packages of programs, fonts, and more for others to install
+Icon = ICON32.BMP
+
+[Modules]
+File0 = ../../../include/basic/commdlg.bas
+File1 = ../../../include/basic/fileops.bas
+File2 = ../../../include/basic/help.bas
+File3 = ../../../include/basic/pkg.bas
+File4 = ../../../include/basic/prefs.bas
+File5 = ../../../include/basic/shell.bas
+File6 = pkgbuild.bas
+Count = 7
+
+[Forms]
+File0 = pkgbuild.frm
+Count = 1
+
+[Settings]
+StartupForm = BuilderForm
diff --git a/src/apps/kpunch/pkgbuild/pkgbuild.dhs b/src/apps/kpunch/pkgbuild/pkgbuild.dhs
new file mode 100644
index 00000000..0e3fd062
--- /dev/null
+++ b/src/apps/kpunch/pkgbuild/pkgbuild.dhs
@@ -0,0 +1,160 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+# Package Builder help. Compiled to pkgbuild.hlp next to pkgbuild.app;
+# F1 and Help > Contents in the Package Builder window open it.
+
+.topic pkgbuild.overview
+.title Package Builder
+.toc 0 Package Builder
+.default
+.index Package Builder
+.index Making packages
+.index DPK files
+
+.h1 Package Builder
+
+Package Builder makes packages: single files, ending in .DPK, that hold something to add to DVX -- a program, a library or widget other programs use, fonts, sounds, themes, desktop pictures, or MIDI instruments -- for other people to install with Software.
+
+Describe the package on the five pages of the window, then choose Build Package and say where to save it:
+
+.list
+.item Package: its name, version, and kind.
+.item Credits: who made it, and on what terms it may be used.
+.item Needs: other packages it needs to work.
+.item Files: the folder whose files go into it.
+.item Signing: the key that shows the package came from you.
+.endlist
+
+What you fill in can be saved as a template (File > Save) and opened again (File > Open) to build the next version.
+
+.link pkgbuild.package The Package page
+.link pkgbuild.needs The Needs page
+.link pkgbuild.files The Files page
+.link pkgbuild.signing Signing a package
+.link pkgbuild.developers For developers
+
+.topic pkgbuild.package
+.title The Package Page
+.toc 1 Package
+.index Package name
+.index Version
+.index Kind of package
+.index Credits
+.index Publisher
+
+.h1 The Package Page
+
+.list
+.item Name: what Software shows, such as Chess.
+.item Package name: who made it and what it is, with a dot between them, such as acme.chess. Each part is 1 to 8 lower-case letters, digits, or _. Two packages with the same package name are the same package, so keep it once you have chosen it.
+.item Version: numbers with dots between them, such as 1.0.0. Give each new build you hand out a higher version: Software installs a higher version over a lower one, but not the other way around.
+.item Kind: what the package holds -- app (a program), library, widget, font, bank (MIDI instruments), theme, wallpaper (desktop pictures), sound, help, or bundle (no files of its own: a set of other packages, named on the Needs page).
+.item Summary: a sentence about what it is.
+.item Oldest DVX: the oldest version of DVX it works with. It starts as this DVX's version; leave it empty when any version will do.
+.endlist
+
+The Credits page holds the Publisher, Author, Licence, and Web site: who made the package, and on what terms it may be used. Software shows them in the package's Details.
+
+.topic pkgbuild.needs
+.title The Needs Page
+.toc 1 Needs
+.index Needs
+.index Dependencies
+
+.h1 The Needs Page
+
+When the package works only with another package installed, add that one here: choose Add, pick it from the packages installed in this DVX, and give the oldest version of it that will do. Software installs what a package needs along with it when it is in the same folder, and refuses to install a package whose needs cannot be met.
+
+Choose a row and Remove to take it off the list.
+
+.topic pkgbuild.files
+.title The Files Page
+.toc 1 Files
+.index Files of a package
+.index Folder of files
+
+.h1 The Files Page
+
+Choose the folder whose files go into the package (Browse), and say how it is laid out:
+
+.list
+.item The folder holds this package's own files: everything in the folder (and in the folders inside it) goes into the package's own folder in DVX, which depends on its kind and package name. The page shows where, and lists each file.
+.item The folder is laid out like the DVX folder: each file goes in the same place in DVX as it has in the folder. Use this when the package puts files in more than one place, for example a program with its help in another folder.
+.endlist
+
+Where each kind of package keeps its files, for a package named acme.chess:
+
+.table
+ Kind Folder in DVX
+ ---- -------------
+ app, help APPS\ACME\CHESS
+ library LIBS\ACME\CHESS
+ widget WIDGETS\ACME\CHESS
+ font SHARED\FONTS\ACME
+ bank SHARED\BANKS\ACME
+ theme SHARED\THEMES\ACME
+ wallpaper SHARED\WPAPER\ACME
+ sound SHARED\SOUNDS\ACME
+.endtable
+
+A package may put files only in its own kind's folder, so it cannot change DVX or another publisher's files. Build Package says so when a file is somewhere else. A file named PACKAGE.INI or PACKAGE.SIG at the top of the folder is left out.
+
+.topic pkgbuild.signing
+.title Signing a Package
+.toc 1 Signing
+.index Signing
+.index Keys
+.index New Key
+
+.h1 Signing a Package
+
+Signing is like a seal on a letter: it shows the people who install the package that it came from you and that nobody changed it on the way. Software marks a package that is not signed by someone the person trusts, and an update must be signed by whoever signed the version they have.
+
+A key comes as two files:
+
+.list
+.item The .KEY file, which you give to the people who install your packages, once. They trust it in Software (File > Trust a Publisher's Key).
+.item The .SEC file, the secret one you sign with. Keep it to yourself and keep a copy somewhere safe: anyone who has it can sign packages as you, and if you lose it you cannot sign updates of your packages.
+.endlist
+
+Choose New Key to make one. It asks the name people see on what you sign and where to save the key; keys start in your KEYS folder, where this DVX trusts them. The Sign with list offers the keys in your KEYS folder; Other Key signs with a .SEC file kept elsewhere, such as on a floppy disk. Choose Not signed to build without signing.
+
+.topic pkgbuild.developers
+.title For Developers
+.toc 1 For Developers
+.index Templates
+.index PACKAGE.INI
+.index DVXPKG
+
+.h1 For Developers
+
+A template is the package's manifest (PACKAGE.INI) without its format line and its list of files: a [package] section with the id, name, version, kind, and the other fields, a [depends] section with one line per need (package = oldest version), and a [builder] section that only Package Builder reads (the folder, how it is laid out, and the key).
+
+The same template builds the package without Package Builder, on DOS with DVXPKG.EXE in DVX's SYSTEM folder:
+
+.code
+DVXPKG build CHESS.INI FOLDER CHESS.DPK MYKEY.SEC
+.endcode
+
+where FOLDER is laid out like the DVX folder. DVXPKG with no arguments lists its other commands: signing a package, checking one, showing what is in one, making keys, and installing, removing, and listing packages.
+
+Programs make packages with the package library (pkg.bas): PkgBuild, PkgSign, PkgKeygen, and the functions around them.
diff --git a/src/apps/kpunch/pkgbuild/pkgbuild.frm b/src/apps/kpunch/pkgbuild/pkgbuild.frm
new file mode 100644
index 00000000..aec51661
--- /dev/null
+++ b/src/apps/kpunch/pkgbuild/pkgbuild.frm
@@ -0,0 +1,472 @@
+VERSION DVX 1.00
+Begin Form BuilderForm
+ Caption = "Package Builder"
+ Layout = VBox
+ AutoSize = False
+ Resizable = True
+ Centered = True
+ Width = 540
+ Height = 400
+ Begin Menu mnuFile
+ Caption = "&File"
+ Begin Menu mnuNew
+ Caption = "&New"
+ End
+ Begin Menu mnuOpen
+ Caption = "&Open..."
+ End
+ Begin Menu mnuSave
+ Caption = "&Save"
+ End
+ Begin Menu mnuSaveAs
+ Caption = "Save &As..."
+ End
+ Begin Menu mnuFileSep1
+ Caption = "-"
+ End
+ Begin Menu mnuBuild
+ Caption = "&Build Package..."
+ End
+ Begin Menu mnuFileSep2
+ Caption = "-"
+ End
+ Begin Menu mnuExit
+ Caption = "E&xit"
+ End
+ End
+ Begin Menu mnuHelp
+ Caption = "&Help"
+ Begin Menu mnuHelpContents
+ Caption = "&Contents"
+ End
+ Begin Menu mnuHelpSep
+ Caption = "-"
+ End
+ Begin Menu mnuAbout
+ Caption = "&About Package Builder..."
+ End
+ End
+ Begin TabStrip tabs
+ Weight = 1
+ Begin TabPage pgPackage
+ Caption = "&Package"
+ Begin HBox rowName
+ Begin Label lblName
+ Caption = "Name:"
+ MinWidth = 112
+ End
+ Begin TextBox txtName
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowId
+ Begin Label lblId
+ Caption = "Package name:"
+ MinWidth = 112
+ End
+ Begin TextBox txtId
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowVersion
+ Begin Label lblVersion
+ Caption = "Version:"
+ MinWidth = 112
+ End
+ Begin TextBox txtVersion
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowKind
+ Begin Label lblKind
+ Caption = "Kind:"
+ MinWidth = 112
+ End
+ Begin DropDown ddKind
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowSummary
+ Begin Label lblSummary
+ Caption = "Summary:"
+ MinWidth = 112
+ End
+ Begin TextBox txtSummary
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowDvx
+ Begin Label lblDvx
+ Caption = "Oldest DVX:"
+ MinWidth = 112
+ End
+ Begin TextBox txtDvx
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ End
+ Begin TabPage pgCredits
+ Caption = "&Credits"
+ Begin HBox rowPublisher
+ Begin Label lblPublisher
+ Caption = "Publisher:"
+ MinWidth = 112
+ End
+ Begin TextBox txtPublisher
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowAuthor
+ Begin Label lblAuthor
+ Caption = "Author:"
+ MinWidth = 112
+ End
+ Begin TextBox txtAuthor
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowLicence
+ Begin Label lblLicence
+ Caption = "Licence:"
+ MinWidth = 112
+ End
+ Begin TextBox txtLicence
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox rowHomepage
+ Begin Label lblHomepage
+ Caption = "Web site:"
+ MinWidth = 112
+ End
+ Begin TextBox txtHomepage
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ End
+ Begin TabPage pgNeeds
+ Caption = "&Needs"
+ Begin Label lblNeedsIntro
+ Caption = "Other packages this one needs to work:"
+ End
+ Begin ListView lvNeeds
+ Weight = 1
+ End
+ Begin HBox boxNeedButtons
+ Alignment = Right
+ Begin CommandButton cmdNeedAdd
+ Caption = "&Add..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdNeedRemove
+ Caption = "&Remove"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ End
+ Begin TabPage pgFiles
+ Caption = "Fi&les"
+ Begin HBox rowFolder
+ Begin Label lblFolder
+ Caption = "Folder:"
+ MinWidth = 112
+ End
+ Begin TextBox txtFolder
+ MinWidth = 200
+ Weight = 1
+ End
+ Begin CommandButton cmdBrowse
+ Caption = "B&rowse..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ Begin VBox boxLayout
+ Begin OptionButton optOwn
+ Caption = "The folder holds this package's own files"
+ End
+ Begin OptionButton optDvx
+ Caption = "The folder is laid out like the DVX folder"
+ End
+ End
+ Begin Label lblGoesIn
+ Caption = ""
+ End
+ Begin ListView lvFiles
+ Weight = 1
+ End
+ End
+ Begin TabPage pgSigning
+ Caption = "&Signing"
+ Begin HBox rowKey
+ Begin Label lblKey
+ Caption = "Sign with:"
+ MinWidth = 112
+ End
+ Begin DropDown ddKey
+ MinWidth = 280
+ Weight = 1
+ End
+ End
+ Begin HBox boxKeyButtons
+ Alignment = Right
+ Begin CommandButton cmdNewKey
+ Caption = "N&ew Key..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdOtherKey
+ Caption = "&Other Key..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ Begin RichLabel rlSignNote
+ Width = 460
+ End
+ End
+ End
+ Begin HBox boxButtons
+ Alignment = Right
+ Begin CommandButton cmdBuild
+ Caption = "&Build Package..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ Begin StatusBar sbStatus
+ Begin Label lblStatus
+ Caption = ""
+ Weight = 1
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' pkgbuild.frm -- the Package Builder window: a package's description,
+' what it needs, its files, and its key, kept as a template file, and the
+' package built from them. The work is in pkgbuild.bas.
+
+OPTION EXPLICIT
+
+CONST HELP_FILE = "pkgbuild.hlp"
+
+CONST PB_ABOUT = "Package Builder" & CHR$(10) & CHR$(10) & "Makes add-on packages (.DPK files) of programs, libraries, widgets, fonts, sounds, themes, desktop pictures, and MIDI instruments, signed with your key, for Software to install." & CHR$(10) & CHR$(10) & "Copyright 2026 Scott Duensing"
+
+
+SUB BuilderForm_Load
+ DIM i AS LONG
+
+ pbSession = PkgOpen()
+
+ IF pbSession = 0 THEN
+ PbError "There is not enough memory to start Package Builder."
+ Unload BuilderForm
+ EXIT SUB
+ END IF
+
+ ' Every kind but DVX itself's
+ DO WHILE PkgKindName$(i) <> ""
+ IF PkgKindName$(i) <> PB_SYSTEM_KIND THEN
+ ddKind.AddItem PkgKindName$(i)
+ END IF
+
+ i = i + 1
+ LOOP
+
+ lvNeeds.SetColumns PB_NEED_COLUMNS
+ lvFiles.SetColumns PB_FILE_COLUMNS
+ rlSignNote.AddText "Signing tells the people who install the package that it came from you and was not changed on the way. Give them your key's .KEY file once; keep its .SEC file to yourself. Keys you make start in your KEYS folder, where this DVX trusts them."
+ PbKeysFill ""
+ PbReset
+
+ ' A template opened from File Manager or Program Manager
+ IF COMMAND$(1) <> "" THEN
+ IF PbLoad(COMMAND$(1)) THEN
+ pbPath = COMMAND$(1)
+ pbSaved = PbState$()
+ PbShowTitle
+ ELSE
+ PbError COMMAND$(1) & " cannot be read."
+ END IF
+ END IF
+END SUB
+
+
+SUB BuilderForm_QueryUnload(Cancel AS INTEGER)
+ IF NOT PbSaveFirst() THEN
+ Cancel = 1
+ END IF
+END SUB
+
+
+SUB BuilderForm_Unload
+ PkgClose pbSession
+END SUB
+
+
+SUB cmdBrowse_Click
+ DIM folder AS STRING
+
+ folder = DlgFolder$("Folder of Files", Trim$(txtFolder.Text))
+
+ IF folder <> "" THEN
+ txtFolder.Text = folder
+ PbFilesShow
+ END IF
+END SUB
+
+
+SUB cmdBuild_Click
+ PbBuild
+END SUB
+
+
+SUB cmdNeedAdd_Click
+ PbNeedAdd
+END SUB
+
+
+SUB cmdNeedRemove_Click
+ PbNeedRemove
+END SUB
+
+
+SUB cmdNewKey_Click
+ PbNewKey
+END SUB
+
+
+SUB cmdOtherKey_Click
+ DIM path AS STRING
+
+ path = DlgFileOpen$("Sign with a Key File", PB_SEC_FILTER)
+
+ IF path = "" THEN
+ EXIT SUB
+ END IF
+
+ IF PkgKeyInfo$(pbSession, path) = "" THEN
+ PbError path & " is not a secret key. " & PkgError$(pbSession)
+ EXIT SUB
+ END IF
+
+ PbKeysFill path
+END SUB
+
+
+SUB ddKind_Change
+ PbFilesShow
+END SUB
+
+
+SUB mnuAbout_Click
+ MsgBox PB_ABOUT, vbOKOnly + vbInformation, "About Package Builder"
+END SUB
+
+
+SUB mnuBuild_Click
+ PbBuild
+END SUB
+
+
+SUB mnuExit_Click
+ Unload BuilderForm
+END SUB
+
+
+SUB mnuHelpContents_Click
+ HelpView HELP_FILE
+END SUB
+
+
+SUB mnuNew_Click
+ IF PbSaveFirst() THEN
+ PbReset
+ END IF
+END SUB
+
+
+SUB mnuOpen_Click
+ DIM path AS STRING
+
+ IF NOT PbSaveFirst() THEN
+ EXIT SUB
+ END IF
+
+ path = DlgFileOpen$("Open Template", PB_FILTER)
+
+ IF path = "" THEN
+ EXIT SUB
+ END IF
+
+ IF PbLoad(path) THEN
+ pbPath = path
+ pbSaved = PbState$()
+ PbShowTitle
+ ELSE
+ PbError path & " cannot be read."
+ END IF
+END SUB
+
+
+SUB mnuSave_Click
+ PbSave False
+END SUB
+
+
+SUB mnuSaveAs_Click
+ PbSave True
+END SUB
+
+
+SUB optDvx_Click
+ PbFilesShow
+END SUB
+
+
+SUB optOwn_Click
+ PbFilesShow
+END SUB
+
+
+SUB tabs_Click
+ IF tabs.GetActive() = PB_PAGE_FILES THEN
+ PbFilesShow
+ END IF
+END SUB
diff --git a/src/apps/kpunch/progman/pmProps.c b/src/apps/kpunch/progman/pmProps.c
index 20b461cc..c755a529 100644
--- a/src/apps/kpunch/progman/pmProps.c
+++ b/src/apps/kpunch/progman/pmProps.c
@@ -64,8 +64,9 @@ typedef struct {
// Prototypes
// ============================================================
-static void browseInto(WidgetT *input, const char *title, const FileFilterT *filters, int32_t filterCount);
+static void browseInto(WidgetT *input, const char *title, int32_t flags, const FileFilterT *filters, int32_t filterCount);
static bool hotkeyTaken(const PropsDlgT *pd, int32_t key, int32_t mods, const char **owner);
+static void onBrowseFolder(WidgetT *w);
static void onBrowseIcon(WidgetT *w);
static void onBrowseProgram(WidgetT *w);
static bool onOk(WindowT *dlg, void *userCtx);
@@ -73,10 +74,16 @@ bool pmPropsDialog(AppContextT *ctx, const PmModelT *model, int32_t g
static void showPreview(PropsDlgT *pd);
-static void browseInto(WidgetT *input, const char *title, const FileFilterT *filters, int32_t filterCount) {
+// The file (FD_OPEN) or folder (FD_FOLDER) chosen goes into input. A
+// file is looked for from the programs folder, a folder from the one
+// typed (else the programs folder).
+static void browseInto(WidgetT *input, const char *title, int32_t flags, const FileFilterT *filters, int32_t filterCount) {
char path[DVX_MAX_PATH];
+ char typed[DVX_MAX_PATH];
- if (dvxFileDialog(wgtGetContext(input), title, FD_OPEN, SHELL_PROG_DIR, filters, filterCount, path, sizeof(path))) {
+ dvxCopyTrimmed(typed, sizeof(typed), wgtGetText(input));
+
+ if (dvxFileDialog(wgtGetContext(input), title, flags, ((flags & FD_FOLDER) && typed[0]) ? typed : SHELL_PROG_DIR, filters, filterCount, path, sizeof(path))) {
wgtSetText(input, path);
}
}
@@ -101,6 +108,13 @@ static bool hotkeyTaken(const PropsDlgT *pd, int32_t key, int32_t mods, const ch
}
+static void onBrowseFolder(WidgetT *w) {
+ PropsDlgT *pd = (PropsDlgT *)w->userData;
+
+ browseInto(pd->folder, "Choose Working Folder", FD_FOLDER, NULL, 0);
+}
+
+
static void onBrowseIcon(WidgetT *w) {
PropsDlgT *pd = (PropsDlgT *)w->userData;
const FileFilterT filters[] = {
@@ -108,7 +122,7 @@ static void onBrowseIcon(WidgetT *w) {
{ "All Files (*.*)" }
};
- browseInto(pd->icon, "Choose Icon", filters, DVX_ARRAY_LEN(filters));
+ browseInto(pd->icon, "Choose Icon", FD_OPEN, filters, DVX_ARRAY_LEN(filters));
showPreview(pd);
}
@@ -120,7 +134,7 @@ static void onBrowseProgram(WidgetT *w) {
{ "All Files (*.*)" }
};
- browseInto(pd->program, "Choose Program", filters, DVX_ARRAY_LEN(filters));
+ browseInto(pd->program, "Choose Program", FD_OPEN, filters, DVX_ARRAY_LEN(filters));
showPreview(pd);
}
@@ -200,7 +214,12 @@ bool pmPropsDialog(AppContextT *ctx, const PmModelT *model, int32_t group, int32
progBrowse->onClick = onBrowseProgram;
pd.args = dvxDialogAddField(dlg, "&Arguments:", PP_LABEL_W, PM_ARGS_MAX - 1, item->args);
pd.folder = dvxDialogAddField(dlg, "&Working folder:", PP_LABEL_W, DVX_MAX_PATH - 1, item->folder);
- pd.icon = dvxDialogAddField(dlg, "&Icon file:", PP_LABEL_W, DVX_MAX_PATH - 1, item->iconPath);
+
+ WidgetT *folderBrowse = wgtButton(pd.folder->parent, "Brows&e...");
+
+ folderBrowse->userData = &pd;
+ folderBrowse->onClick = onBrowseFolder;
+ pd.icon = dvxDialogAddField(dlg, "&Icon file:", PP_LABEL_W, DVX_MAX_PATH - 1, item->iconPath);
WidgetT *iconBrowse = wgtButton(pd.icon->parent, "B&rowse...");
diff --git a/src/apps/kpunch/progman/progman.c b/src/apps/kpunch/progman/progman.c
index 739b9990..6e3d8c57 100644
--- a/src/apps/kpunch/progman/progman.c
+++ b/src/apps/kpunch/progman/progman.c
@@ -1142,7 +1142,7 @@ static void setEnabled(int32_t id, bool enabled) {
static void showAboutDialog(void) {
dvxMessageBox(sAc, "About DVX",
- "DVX 1.0 - \"DOS Visual eXecutive\" GUI System.\n\n\"We have Windows at home.\"\n\nCopyright 2026 Scott Duensing\nKangaroo Punch Studios\nhttps://kangaroopunch.com",
+ "DVX " DVX_VERSION_TEXT " - \"DOS Visual eXecutive\" GUI System.\n\n\"We have Windows at home.\"\n\nCopyright 2026 Scott Duensing\nKangaroo Punch Studios\nhttps://kangaroopunch.com",
MB_OK | MB_ICONINFO);
}
diff --git a/src/apps/kpunch/progman/progman.dhs b/src/apps/kpunch/progman/progman.dhs
index 3a5d3ebe..60f97639 100644
--- a/src/apps/kpunch/progman/progman.dhs
+++ b/src/apps/kpunch/progman/progman.dhs
@@ -134,7 +134,7 @@ Right-click an icon for the same commands.
Name The name shown under the icon. Leave it empty to use the program's own name.
Program The program to start. Click Browse... to pick it. It can also be a document, such as a text file: it then opens in the program set up for its type (see Running a Command).
Arguments Extra information given to the program when it starts, such as the name of a file to open.
- Working folder The folder where files named in Arguments are looked for. For example, with the working folder LETTERS and the argument JUNE.TXT, the program gets LETTERS\JUNE.TXT.
+ Working folder The folder where files named in Arguments are looked for. Click Browse... beside it to pick it. For example, with the working folder LETTERS and the argument JUNE.TXT, the program gets LETTERS\JUNE.TXT.
Icon file A different picture for the icon: a program file (its icon is used) or a picture (BMP, PNG, JPEG, or GIF). Leave it empty for the program's own icon. The small picture beside Run minimized shows the icon.
Shortcut key Keys that start the item from any program, whichever window is in front, such as Ctrl+Alt+N. They work while the Program Manager is running (it may be minimized), but not while a menu or a dialog is open. A shortcut is Ctrl together with Alt or Shift (or both), plus a letter, a digit, or F1 to F12. Each shortcut can belong to one item only.
Run minimized Start the program with its windows made into icons.
diff --git a/src/apps/kpunch/progman/userHelp.dhs b/src/apps/kpunch/progman/userHelp.dhs
index 91acd78f..399590ff 100644
--- a/src/apps/kpunch/progman/userHelp.dhs
+++ b/src/apps/kpunch/progman/userHelp.dhs
@@ -452,6 +452,7 @@ A scroll bar lets you see the part of a window that does not fit. Click the arro
.index Files
.index Folders
.index Drives
+.index Choosing a Folder
.h1 Opening and Saving Files
@@ -475,6 +476,10 @@ Double-click a folder to look inside it. Click a file to put its name in the Fil
Go to the folder you want, type a name in the File name box, and click Save. If you leave off the ending (such as .txt), the dialog adds the one from the Filter box. If a file with that name already exists, DVX asks whether to replace it.
+.h2 Choosing a Folder
+
+Where a program needs a folder rather than a file, the Browse... button beside it opens the Choose a Folder dialog. It works like the Open dialog but lists folders only. Go into the folder you want and click OK. To choose a new folder, type its name in the Folder name box and click OK: the program makes it when it needs it.
+
.note tip
Alt+D opens the Drive list from the keyboard. You can also change drives by typing the drive letter and a colon, such as A:, in the Path box and pressing Enter.
.endnote
@@ -525,7 +530,7 @@ Programs that can print have Print... in their File menu, and programs that prin
.h2 The Print Dialog
-File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print.
+File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.
While a printout is going to the printer, a small Printing window shows it is busy. You can keep working in other programs, and Cancel stops the printout.
@@ -558,7 +563,7 @@ Pictures are printed in black and white, as large as fits on the page. Printing
.h2 Finding and Starting a Program
-Every program installed in DVX appears as an icon in the Program Manager, in its Installed Programs group, and you can put icons for the programs you use most into groups of your own. Select an icon to see a short description of the program in the Program Manager's status bar.
+Every program installed in DVX appears as an icon in the Program Manager, in its Installed Programs group, and you can put icons for the programs you use most into groups of your own. Programs you add later come as packages you install with the Software program (see Adding and Removing Software). Select an icon to see a short description of the program in the Program Manager's status bar.
Double-click an icon to start that program, or select it with the arrow keys and press Enter. The program opens in a new window. While it is loading, the mouse pointer shows an hourglass. The Program Manager's Search box finds a program in every group as you type its name.
@@ -605,6 +610,7 @@ If a program has no help of its own, F1 opens this guide instead.
.link user.help Getting Help
.link user.taskmgr Task Manager
+.link user.addons Adding and Removing Software
.topic user.progman
.title The Program Manager
@@ -632,6 +638,54 @@ You can make your own groups, add, move, and copy icons, give an icon a shortcut
Closing the Program Manager window leaves DVX, after asking you first.
.endnote
+.topic user.addons
+.title Adding and Removing Software
+.toc 0 Adding and Removing Software
+.index Software
+.index Add-ons
+.index Installing programs
+.index Removing programs
+.index Packages
+.index DPK files
+.index Package Builder
+.index Package server
+.index Updating DVX
+
+.h1 Adding and Removing Software
+
+Programs, fonts, sounds, themes, desktop pictures, MIDI instruments, and the libraries and widgets other programs use can be added to DVX after it is installed. Each comes as a package: one file whose name ends in .DPK.
+
+.h2 Installing a Package
+
+Open the .DPK file in File Manager, or start the Software program and choose Install from File. Software shows what will be installed first -- including other packages it needs from the same folder -- who signed it, and how much disk space it takes, then installs it when you choose Install. A new program appears in the Program Manager's Installed Programs group; new fonts, sounds, themes, desktop pictures, and instruments appear where you choose them in the settings program. Libraries and widgets work from the next time DVX starts.
+
+Software can also get packages from the package server over the network: its Available page lists every package the server offers. Choose one and Install; Software fetches it with any packages it needs, then shows what will be installed exactly as for a file.
+
+A package can be signed by the people who made it, like a seal on a letter. Software tells you when a package is not signed by anyone you trust: install it only if you trust where it came from. To trust a publisher, get their key file (it ends in .KEY) and choose Trust a Publisher's Key in Software's File menu.
+
+.h2 Keeping DVX Up to Date
+
+Software's Updates page asks the package server what is newer than what you have, DVX itself included, and Update All installs it. When DVX itself is updated, the new version runs from the next time DVX starts; Software lists any add-ons that will stop working with it until they are updated too. The package server's address and port are set with Package Server in Software's File menu.
+
+.h2 Publishing Your Own Packages
+
+With an account on the package server (its email and password given in the same Package Server window), Software's File > Publish a Package sends a package you made to the server. It is offered to everyone once the server's operator has reviewed it; Software's Published page shows how the review went.
+
+.h2 Removing a Package
+
+Software's Installed list shows every package in DVX. Choose one and Remove. If other packages need it, Software names them and removes them too (or nothing); files you changed since they were installed are kept unless you say otherwise.
+
+.h2 Making Your Own Packages
+
+Package Builder makes a package of your own program, fonts, or other files, for other people to install with Software, and signs it with a key of yours. The DVX BASIC IDE makes a package of a BASIC program in one step: File > Make Package. Their help explains how.
+
+.h2 If DVX Will Not Start
+
+If DVX does not start after you installed something, hold down a Shift key while DVX starts. DVX then starts without the libraries and widgets add-ons installed, so you can remove the one that causes the trouble with Software. See Troubleshooting.
+
+.link user.programs Running Programs
+.link user.trouble Troubleshooting
+
.topic user.settings
.title Changing Settings
.toc 0 Changing Settings
@@ -810,7 +864,9 @@ Programs can have their own shortcut keys. Look for them at the right edge of ea
.index Installing DVX
.index Setup
.index Uninstalling DVX
+.index Removing DVX
.index Upgrading DVX
+.index Repairing DVX
.index Read-only Disk
.index Empty Floppy Drive
@@ -832,6 +888,12 @@ If the screen resolution DVX is set to use does not work on your computer (for e
If DVX starts but the screen cannot be read (the monitor cannot show the saved resolution), hold down a Shift key while DVX starts. DVX then starts in safe video mode and asks the same question. Typing DVX /SAFE at the DOS prompt does the same.
+Starting this way is a safe start: DVX also leaves out the libraries and widgets that add-on packages installed, in case one of them is what stops DVX from starting. A message says so once the desktop is up. Start DVX the usual way to use them again, or remove the add-on that causes the trouble with the Software program.
+
+.h2 Add-ons that could not be loaded
+
+When an add-on package's library or widget cannot be loaded (it was made for another version of DVX, it needs something that is not installed, or its file is damaged), DVX starts without it and a message once the desktop is up lists what was left out and why. The rest of DVX works as usual. Reinstall the add-on (a version made for this DVX), or remove it with the Software program.
+
You can pick another resolution at any time with the settings program (see Changing Settings).
.h2 There is no sound
@@ -844,11 +906,13 @@ When a program tries to read a drive that has no disk in it, or to write to a wr
.h2 Installing, upgrading, and removing DVX
-DVX is installed with SETUP, from the DVX CD or from Disk 1 of the DVX floppy disks: change to that drive and type SETUP. Setup shows the licence, asks for the folder to install DVX in and which fonts to install, checks that there is room, and unpacks DVX there, asking for each floppy disk it needs in turn. It can also make DVX start when the computer starts (it shows the line it adds to AUTOEXEC.BAT and keeps a copy of the old file as AUTOEXEC.DVX). Setup never writes to the CD or the floppy disks.
+DVX is installed with SETUP, from the DVX CD or from Disk 1 of the DVX floppy disks: change to that drive and type SETUP. After the licence, Setup asks what you would like to do: Install DVX, Upgrade or repair DVX, or Remove DVX. Click the picture or the button of your choice. To install, choose the folder for DVX (type it, or click Browse... to pick it; a folder that does not exist yet is made) and which fonts to install. Setup checks that there is room and unpacks DVX there, asking for each floppy disk it needs in turn. It can also make DVX start when the computer starts (it shows the line it adds to AUTOEXEC.BAT and keeps a copy of the old file as AUTOEXEC.DVX). Setup never writes to the CD or the floppy disks.
The fonts are up to you. None keeps DVX to its own font; the base set adds DejaVu Sans, Sans Mono, and Serif in all their styles (about 2 MB); and All adds every font that comes with DVX (about 8 MB). A program that asks for a font you did not install shows its text in DVX's own font instead, unless it carries that font itself (some programs do, such as Welcome and the DVX Demo). Every font file holds its own licence: the Licence button of the Font dialog shows it. With None or the base set, Setup does not ask for the last floppy disks, which hold only fonts.
-To upgrade or repair DVX, run Setup again and choose the same folder: answer Yes to keep your settings and saved files (the USER folder), or No to remove them and start over. Setup starts with the fonts that folder already has; choose others to add fonts or remove them. To remove DVX, run SETUP in the DVX folder (for example C:\DVX\SETUP) and choose Uninstall. Files you added to the folder yourself are not removed, and the USER folder is only removed if you ask.
+To upgrade or repair DVX, run Setup again and choose Upgrade or repair DVX, with the folder DVX is in. (When DVX is in the usual folder, such as C:\DVX, Setup says so and suggests this choice.) Your settings and saved files (the USER folder) are kept, unless you tick Start over to remove them. Setup starts with the fonts that folder already has; choose others to add fonts or remove them. If you choose Install DVX for a folder that already has DVX, Setup offers to upgrade it instead.
+
+To remove DVX, run Setup and choose Remove DVX, with the folder DVX is in. The SETUP in the DVX folder (for example C:\DVX\SETUP) can remove DVX too, from its own folder only. Files you added to the folder yourself are not removed, and the USER folder is only removed if you ask.
.h2 Running DVX from a CD-ROM or a write-protected disk
diff --git a/src/apps/kpunch/software/ICON32.BMP b/src/apps/kpunch/software/ICON32.BMP
new file mode 100644
index 00000000..c9521c3f
--- /dev/null
+++ b/src/apps/kpunch/software/ICON32.BMP
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0511d602380d42fafcc135e8057621226ad67c427db3cf1e514d1b77cedd2d22
+size 3126
diff --git a/src/apps/kpunch/software/software.bas b/src/apps/kpunch/software/software.bas
new file mode 100644
index 00000000..03c12f0a
--- /dev/null
+++ b/src/apps/kpunch/software/software.bas
@@ -0,0 +1,691 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' software.bas -- Software: what its windows share.
+'
+' One session of the package library (pkg.bas) is open while the program
+' runs (swSession): what is installed, the packages read from a file's
+' folder (or fetched from the package server), the plan, and the running
+' job. Three handles on the package server (pkgnet.bas) are open beside
+' it: swNet for the catalogue and fetching, swUpd for the updates, swMine
+' for publishing and the account's list, so each list keeps its own
+' answer; all three are opened again when the server's settings change
+' (swServer.frm, which leaves swServerChanged True). Installing asks first (swPlan.frm,
+' which leaves its answer in swPlanAccepted) and removing asks about what
+' needs the packages and what only they needed; both run as a job
+' SwRunJob runs a slice at a time while the progress window (swProg.frm)
+' shows how far it is, as SwNetRun does with the server's jobs; swJob
+' says which job the window's Cancel stops.
+
+OPTION EXPLICIT
+
+CONST SW_TITLE = "Software"
+CONST SW_TAB = CHR$(9) ' between the cells of a pkg.bas row
+CONST SW_EOL = CHR$(10) ' after each row
+CONST SW_DVX_ID = "kpunch.dvx" ' DVX itself: removed with Setup
+CONST SW_FILTER = "Packages (*.dpk)|All Files (*.*)"
+CONST SW_KEY_FILTER = "Publisher Keys (*.key)|All Files (*.*)"
+CONST SW_LIST_LEAD = " " ' before each name in a message's list
+CONST SW_SLICE_MS = 60 ' job work between screen updates
+
+' The job the progress window shows (swJob)
+CONST SW_JOB_NONE = 0
+CONST SW_JOB_PKG = 1 ' installing or removing (swSession)
+CONST SW_JOB_NET = 2 ' the package server (swJobNet)
+
+' The session (PkgOpen), and the package server's handles (PkgNetOpen),
+' open while the program runs
+DIM SHARED swSession AS LONG
+DIM SHARED swNet AS LONG
+DIM SHARED swUpd AS LONG
+DIM SHARED swMine AS LONG
+DIM SHARED swServerChanged AS BOOLEAN
+
+' The install question's answer, the job running, the server's handle
+' it is on, and whether its Cancel was pressed
+DIM SHARED swPlanAccepted AS BOOLEAN
+DIM SHARED swJob AS LONG
+DIM SHARED swJobNet AS LONG
+DIM SHARED swCancelled AS BOOLEAN
+
+
+' A plan row's action as the list shows it.
+FUNCTION SwActionText$(BYVAL action AS STRING) AS STRING
+ SELECT CASE action
+ CASE "upgrade"
+ SwActionText$ = "Upgrade"
+ CASE "reinstall"
+ SwActionText$ = "Reinstall"
+ CASE ELSE
+ SwActionText$ = "New"
+ END SELECT
+END FUNCTION
+
+
+' The catalogue's rows (PkgNetRows$) as the Available list shows them:
+' name, version, kind, summary, the version installed, id. With find,
+' only those whose name, summary, or id has it (any case).
+FUNCTION SwAvailableRows$(BYVAL rows AS STRING, BYVAL find AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM text AS STRING
+ DIM wanted AS STRING
+
+ wanted = LCase$(Trim$(find))
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+
+ IF wanted = "" OR InStr(LCase$(cells(1) & SW_TAB & cells(4) & SW_TAB & cells(0)), wanted) > 0 THEN
+ text = text & cells(1) & SW_TAB & cells(2) & SW_TAB & cells(3) & SW_TAB & cells(4) & SW_TAB & PkgField$(swSession, cells(0), PKG_FIELD_VERSION) & SW_TAB & cells(0) & SW_EOL
+ END IF
+ END IF
+ NEXT row
+
+ SwAvailableRows$ = text
+END FUNCTION
+
+
+' Stop the job the progress window shows: an install or removal after
+' the package it is on, the package server's at once.
+SUB SwCancel
+ swCancelled = True
+
+ SELECT CASE swJob
+ CASE SW_JOB_PKG
+ PkgRunCancel swSession
+ CASE SW_JOB_NET
+ PkgNetCancel swJobNet
+ END SELECT
+END SUB
+
+
+SUB SwError(BYVAL message AS STRING)
+ MsgBox message, vbOKOnly + vbCritical, SW_TITLE
+END SUB
+
+
+' Fetch the packages ids (one per line) with the package server's handle
+' net (at the versions its last answer gives) with what they need, then
+' install them as a file's are; what was fetched is removed after. what
+' names them in messages.
+SUB SwFetchInstall(BYVAL net AS LONG, BYVAL ids AS STRING, BYVAL what AS STRING)
+ DIM id AS VARIANT
+ DIM offers AS STRING
+ DIM offer AS LONG
+
+ IF NOT SwNetRun(net, PkgNetFetch(net, ids), "Fetching") THEN
+ EXIT SUB
+ END IF
+
+ IF PkgOfferFolder(swSession, PkgNetDir$(net)) <= 0 THEN
+ SwError what & " cannot be installed: what was fetched cannot be read."
+ PkgNetClear net
+ EXIT SUB
+ END IF
+
+ FOR EACH id IN Split(ids, SW_EOL)
+ IF id <> "" THEN
+ offer = PkgOfferIndex(swSession, id)
+
+ IF offer >= 0 THEN
+ offers = offers & IIF(offers = "", "", ",") & CStr(offer)
+ END IF
+ END IF
+ NEXT id
+
+ SwInstallOffers offers, what, "the package server"
+ PkgNetClear net
+END SUB
+
+
+' A field (PKG_FIELD_*) of the package id: installed (net 0), or as the
+' package server's handle net lists it.
+FUNCTION SwField$(BYVAL net AS LONG, BYVAL id AS STRING, BYVAL field AS LONG) AS STRING
+ IF net = 0 THEN
+ SwField$ = PkgField$(swSession, id, field)
+ ELSE
+ SwField$ = PkgNetField$(net, id, field)
+ END IF
+END FUNCTION
+
+
+' The ids in rows (their first cells), one per line.
+FUNCTION SwIds$(BYVAL rows AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM text AS STRING
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ text = text & Split(row, SW_TAB)(0) & SW_EOL
+ END IF
+ NEXT row
+
+ SwIds$ = text
+END FUNCTION
+
+
+' Install the package file path, with what it needs from the packages
+' beside it: what will happen is shown first, then it runs.
+SUB SwInstallFile(BYVAL path AS STRING)
+ DIM offer AS LONG
+
+ offer = PkgOfferFile(swSession, path)
+
+ IF offer < 0 THEN
+ SwError path & " cannot be installed: " & PkgError$(swSession)
+ EXIT SUB
+ END IF
+
+ SwInstallOffers CStr(offer), PkgOfferField$(swSession, offer, PKG_FIELD_NAME), path
+END SUB
+
+
+' Plan installing the offers (their numbers, separated by commas) with
+' what they need, ask (swPlan.frm), and install. what names them in
+' messages; source (a file, the package server) is logged with the ids
+' installed, before the message saying so.
+SUB SwInstallOffers(BYVAL offers AS STRING, BYVAL what AS STRING, BYVAL source AS STRING)
+ DIM restart AS BOOLEAN
+ DIM dvx AS BOOLEAN
+ DIM names AS STRING
+ DIM ids AS STRING
+
+ IF NOT PkgPlan(swSession, offers) THEN
+ SwError what & " cannot be installed. " & PkgError$(swSession)
+ EXIT SUB
+ END IF
+
+ swPlanAccepted = False
+ Load PlanForm
+ SwPlanFill
+ PlanForm.Show vbModal
+
+ IF NOT swPlanAccepted THEN
+ EXIT SUB
+ END IF
+
+ ' The plan is used up by installing: what it says is read first
+ restart = PkgPlanRestart(swSession)
+ dvx = SwPlanHas(SW_DVX_ID)
+ names = SwNameList$(PkgPlanRows$(swSession))
+ ids = Trim$(Replace(SwIds$(PkgPlanRows$(swSession)), SW_EOL, " "))
+
+ IF NOT PkgInstallStart(swSession) THEN
+ SwError what & " cannot be installed now."
+ EXIT SUB
+ END IF
+
+ IF SwRunJob("Installing") <> PKG_RUN_DONE THEN
+ SwError "Installing did not finish. " & PkgRunMessage$(swSession)
+ EXIT SUB
+ END IF
+
+ ShellLog "Software: installed from " & source & ": " & ids
+
+ IF dvx THEN
+ MsgBox "Installed: " & names & ". Restart DVX to start using the new version of DVX.", vbOKOnly + vbInformation, SW_TITLE
+ ELSEIF restart THEN
+ MsgBox "Installed: " & names & ". Libraries and widgets work from the next time DVX starts.", vbOKOnly + vbInformation, SW_TITLE
+ ELSE
+ MsgBox "Installed: " & names & ".", vbOKOnly + vbInformation, SW_TITLE
+ END IF
+END SUB
+
+
+' The account's versions (PkgNetMineRows$) as the Published list shows
+' them: name, version, the review, its note, id.
+FUNCTION SwMineRows$(BYVAL rows AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM text AS STRING
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+ text = text & cells(1) & SW_TAB & cells(2) & SW_TAB & SwStateText$(cells(3)) & SW_TAB & cells(4) & SW_TAB & cells(0) & SW_EOL
+ END IF
+ NEXT row
+
+ SwMineRows$ = text
+END FUNCTION
+
+
+' The names in rows (id, name, ...), separated by commas: for one line.
+FUNCTION SwNameList$(BYVAL rows AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM text AS STRING
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ IF text <> "" THEN
+ text = text & ", "
+ END IF
+
+ text = text & Split(row, SW_TAB)(1)
+ END IF
+ NEXT row
+
+ SwNameList$ = text
+END FUNCTION
+
+
+' The names in rows (id, name, ...), one per line, indented: for a
+' message.
+FUNCTION SwNames$(BYVAL rows AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM text AS STRING
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ text = text & SW_LIST_LEAD & Split(row, SW_TAB)(1) & SW_EOL
+ END IF
+ NEXT row
+
+ SwNames$ = text
+END FUNCTION
+
+
+' Run the package server's job just started on net (started: whether it
+' did), a slice at a time with the progress window up (verb is its
+' caption); its Cancel stops the job. True when it finished; else why is
+' shown, unless it was stopped.
+FUNCTION SwNetRun(BYVAL net AS LONG, BYVAL started AS BOOLEAN, BYVAL verb AS STRING) AS BOOLEAN
+ DIM rc AS LONG
+
+ IF NOT started THEN
+ EXIT FUNCTION
+ END IF
+
+ Load ProgForm
+ swJob = SW_JOB_NET
+ swJobNet = net
+ swCancelled = False
+ ProgForm.Caption = verb
+ ProgForm.lblWhat.Caption = verb
+ ProgForm.pbJob.Value = 0
+ ProgForm.Show
+
+ DO
+ rc = PkgNetRunFor(net, SW_SLICE_MS)
+ ProgForm.lblWhat.Caption = Trim$(verb & " " & PkgNetCurrent$(net))
+ ProgForm.pbJob.Value = PkgNetPercent(net)
+ DoEvents
+ LOOP WHILE rc = PKGNET_BUSY
+
+ swJob = SW_JOB_NONE
+ Unload ProgForm
+
+ IF rc = PKGNET_DONE THEN
+ SwNetRun = True
+ ELSEIF NOT swCancelled THEN
+ SwError PkgNetError$(net)
+ END IF
+END FUNCTION
+
+
+' Fill the install question from the plan: what goes in (and why), who
+' signed it, what the space and a restart mean.
+SUB SwPlanFill
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM rows AS STRING
+ DIM unsigned AS STRING
+ DIM needed AS STRING
+ DIM stale AS STRING
+ DIM freeBytes AS DOUBLE
+ DIM bytes AS DOUBLE
+
+ FOR EACH row IN Split(PkgPlanRows$(swSession), SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+ rows = rows & cells(1) & SW_TAB & cells(2) & SW_TAB & SwActionText$(cells(3)) & SW_TAB & SwTrustText$(cells(5)) & SW_EOL
+
+ IF cells(4) = "no" THEN
+ needed = needed & IIF(needed = "", "", ", ") & cells(1)
+ END IF
+
+ IF cells(5) = "none" THEN
+ unsigned = unsigned & IIF(unsigned = "", "", ", ") & cells(1)
+ END IF
+ END IF
+ NEXT row
+
+ PlanForm.lvPlan.SetColumns "Name,18|Version,9|Action,10|Signed,10"
+ PlanForm.lvPlan.SetRows rows
+ PlanForm.rlNotes.Clear
+
+ IF needed <> "" THEN
+ PlanForm.rlNotes.AddText "Installed with it, because it needs them: " & needed & "."
+ PlanForm.rlNotes.AddParagraph 0
+ END IF
+
+ IF unsigned <> "" THEN
+ PlanForm.rlNotes.SetStyle FONT_BOLD
+ PlanForm.rlNotes.AddText "Not signed by a publisher DVX trusts: "
+ PlanForm.rlNotes.SetStyle FONT_REGULAR
+ PlanForm.rlNotes.AddText unsigned & ". Install them only if you trust where they came from."
+ PlanForm.rlNotes.AddParagraph 0
+ END IF
+
+ stale = PkgPlanStale$(swSession)
+
+ IF stale <> "" THEN
+ PlanForm.rlNotes.AddText "These add-ons stop working with the new DVX until they are updated: " & SwNameList$(stale) & "."
+ PlanForm.rlNotes.AddParagraph 0
+ END IF
+
+ IF SwPlanHas(SW_DVX_ID) THEN
+ PlanForm.rlNotes.AddText "DVX itself is updated: the new version runs from the next time DVX starts."
+ PlanForm.rlNotes.AddParagraph 0
+ ELSEIF PkgPlanRestart(swSession) THEN
+ PlanForm.rlNotes.AddText "Libraries and widgets work from the next time DVX starts."
+ PlanForm.rlNotes.AddParagraph 0
+ END IF
+
+ bytes = PkgPlanBytes(swSession)
+ freeBytes = FileOpsFreeBytes(".")
+
+ IF freeBytes >= 0 AND freeBytes < bytes THEN
+ PlanForm.rlNotes.AddText "There is not enough room on the disk: it needs " & FileOpsFormatSize$(bytes) & " bytes and " & FileOpsFormatSize$(freeBytes) & " are free."
+ ELSE
+ PlanForm.rlNotes.AddText "It takes " & FileOpsFormatSize$(bytes) & " bytes of disk space."
+ END IF
+END SUB
+
+
+' True when the plan installs the package id.
+FUNCTION SwPlanHas(BYVAL id AS STRING) AS BOOLEAN
+ DIM row AS VARIANT
+
+ FOR EACH row IN Split(PkgPlanRows$(swSession), SW_EOL)
+ IF row <> "" THEN
+ IF Split(row, SW_TAB)(0) = id THEN
+ SwPlanHas = True
+ END IF
+ END IF
+ NEXT row
+END FUNCTION
+
+
+' Publish the package file path on the package server with the
+' settings' account: True when the server took it in for review.
+FUNCTION SwPublish(BYVAL path AS STRING) AS BOOLEAN
+ DIM offer AS LONG
+ DIM what AS STRING
+
+ offer = PkgOfferFile(swSession, path)
+
+ IF offer < 0 THEN
+ SwError path & " cannot be published: " & PkgError$(swSession)
+ EXIT FUNCTION
+ END IF
+
+ what = PkgOfferField$(swSession, offer, PKG_FIELD_NAME) & " " & PkgOfferField$(swSession, offer, PKG_FIELD_VERSION)
+
+ IF NOT SwNetRun(swMine, PkgNetUpload(swMine, path), "Publishing") THEN
+ EXIT FUNCTION
+ END IF
+
+ ShellLog "Software: published " & PkgOfferField$(swSession, offer, PKG_FIELD_ID) & " " & PkgOfferField$(swSession, offer, PKG_FIELD_VERSION)
+ MsgBox what & " is on the package server, waiting for its review. The Published page shows how the review goes.", vbOKOnly + vbInformation, SW_TITLE
+ SwPublish = True
+END FUNCTION
+
+
+' Remove the installed package id, after asking about what needs it (it
+' goes too, or nothing does), what only it needed (offered), and files
+' changed since they were installed (kept unless the user says).
+SUB SwRemove(BYVAL id AS STRING)
+ DIM pkgName AS STRING
+ DIM needers AS STRING
+ DIM ids AS STRING
+ DIM orphans AS STRING
+ DIM kept AS STRING
+ DIM keptFile AS VARIANT
+
+ IF id = SW_DVX_ID THEN
+ SwError "DVX itself is removed with Setup."
+ EXIT SUB
+ END IF
+
+ pkgName = PkgField$(swSession, id, PKG_FIELD_NAME)
+ needers = PkgDependents$(swSession, id)
+
+ IF needers <> "" THEN
+ IF MsgBox("These need " & pkgName & " and are removed with it:" & SW_EOL & SW_EOL & SwNames$(needers) & SW_EOL & "Remove them all?", vbYesNo + vbQuestion, SW_TITLE) <> vbYes THEN
+ EXIT SUB
+ END IF
+ ELSEIF MsgBox("Remove " & pkgName & "?", vbYesNo + vbQuestion, SW_TITLE) <> vbYes THEN
+ EXIT SUB
+ END IF
+
+ ' What needs it goes first
+ ids = SwIds$(needers) & id & SW_EOL
+ orphans = PkgOrphans$(swSession, ids)
+
+ IF orphans <> "" THEN
+ IF MsgBox("These were installed for " & pkgName & " and nothing else needs them:" & SW_EOL & SW_EOL & SwNames$(orphans) & SW_EOL & "Remove them too?", vbYesNo + vbQuestion, SW_TITLE) = vbYes THEN
+ ids = ids & SwIds$(orphans)
+ END IF
+ END IF
+
+ IF NOT PkgRemoveStart(swSession, ids, False) THEN
+ SwError pkgName & " cannot be removed now."
+ EXIT SUB
+ END IF
+
+ IF SwRunJob("Removing") <> PKG_RUN_DONE THEN
+ SwError "Removing did not finish. " & PkgRunMessage$(swSession)
+ END IF
+
+ kept = PkgRunKept$(swSession)
+
+ IF kept <> "" THEN
+ IF MsgBox("These files changed since they were installed, so they were left:" & SW_EOL & SW_EOL & kept & SW_EOL & "Delete them too?", vbYesNo + vbQuestion, SW_TITLE) = vbYes THEN
+ FOR EACH keptFile IN Split(kept, SW_EOL)
+ IF keptFile <> "" THEN
+ FileOpsDeleteTree keptFile
+ END IF
+ NEXT keptFile
+ END IF
+ END IF
+
+ ShellLog "Software: removed " & id
+END SUB
+
+
+' Open the package server's handles again, on what the settings name now.
+SUB SwReopen
+ PkgNetClose swNet
+ PkgNetClose swUpd
+ PkgNetClose swMine
+ swNet = PkgNetOpen("")
+ swUpd = PkgNetOpen("")
+ swMine = PkgNetOpen("")
+END SUB
+
+
+' Run the job started on the session, a slice at a time, with the
+' progress window up (verb is its caption: "Installing", "Removing"); its
+' Cancel stops the job after the package it is on. How it ended
+' (PKG_RUN_*).
+FUNCTION SwRunJob(BYVAL verb AS STRING) AS LONG
+ DIM rc AS LONG
+
+ Load ProgForm
+ swJob = SW_JOB_PKG
+ ProgForm.Caption = verb
+ ProgForm.lblWhat.Caption = verb
+ ProgForm.pbJob.Value = 0
+ ProgForm.Show
+
+ DO
+ rc = PkgRunFor(swSession, SW_SLICE_MS)
+ ProgForm.lblWhat.Caption = verb & " " & PkgRunCurrent$(swSession)
+ ProgForm.pbJob.Value = PkgRunPercent(swSession)
+ DoEvents
+ LOOP WHILE rc = PKG_RUN_BUSY
+
+ swJob = SW_JOB_NONE
+ Unload ProgForm
+ SwRunJob = rc
+END FUNCTION
+
+
+' Fill the details window (loaded first) with what the package id is,
+' needs, and holds: one installed (net 0), or one the package server's
+' handle net has listed (whose files are not known until it is fetched).
+SUB SwShowDetails(BYVAL id AS STRING, BYVAL net AS LONG)
+ DIM rows AS STRING
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM needs AS STRING
+ DIM signer AS STRING
+ DIM depends AS STRING
+
+ IF net = 0 THEN
+ depends = PkgDependsRows$(swSession, id)
+ signer = PkgField$(swSession, id, PKG_FIELD_SIGNER_NAME)
+
+ IF signer = "" AND PkgField$(swSession, id, PKG_FIELD_SIGNER) <> "" THEN
+ signer = "key " & PkgField$(swSession, id, PKG_FIELD_SIGNER)
+ ELSEIF signer = "" THEN
+ signer = "nobody"
+ END IF
+ ELSE
+ depends = PkgNetDependsRows$(net, id)
+ signer = "checked when it is installed"
+ END IF
+
+ FOR EACH row IN Split(depends, SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+
+ IF needs <> "" THEN
+ needs = needs & ", "
+ END IF
+
+ needs = needs & cells(0) & " " & cells(1) & " or later"
+ END IF
+ NEXT row
+
+ rows = rows & "Name" & SW_TAB & SwField$(net, id, PKG_FIELD_NAME) & SW_EOL
+ rows = rows & "Version" & SW_TAB & SwField$(net, id, PKG_FIELD_VERSION) & SW_EOL
+ rows = rows & "Kind" & SW_TAB & SwField$(net, id, PKG_FIELD_KIND) & SW_EOL
+ rows = rows & "Summary" & SW_TAB & SwField$(net, id, PKG_FIELD_SUMMARY) & SW_EOL
+ rows = rows & "Publisher" & SW_TAB & SwField$(net, id, PKG_FIELD_PUBLISHER) & SW_EOL
+ rows = rows & "Author" & SW_TAB & SwField$(net, id, PKG_FIELD_AUTHOR) & SW_EOL
+ rows = rows & "Licence" & SW_TAB & SwField$(net, id, PKG_FIELD_LICENCE) & SW_EOL
+ rows = rows & "Web site" & SW_TAB & SwField$(net, id, PKG_FIELD_HOMEPAGE) & SW_EOL
+ rows = rows & "Signed by" & SW_TAB & signer & SW_EOL
+ rows = rows & "Needs" & SW_TAB & needs & SW_EOL
+
+ IF net = 0 THEN
+ rows = rows & "Needed by" & SW_TAB & SwNameList$(PkgNeededBy$(swSession, id)) & SW_EOL
+ ELSE
+ rows = rows & "Size" & SW_TAB & FileOpsFormatSize$(Val(SwField$(net, id, PKG_FIELD_SIZE))) & " bytes" & SW_EOL
+ END IF
+
+ rows = rows & "Package" & SW_TAB & id & SW_EOL
+
+ Load DetailForm
+ DetailForm.Caption = SwField$(net, id, PKG_FIELD_NAME)
+ DetailForm.lvInfo.SetColumns "What,10|Value,40"
+ DetailForm.lvInfo.SetRows rows
+ DetailForm.lvFiles.SetColumns "File,34|Size,10,Right"
+ DetailForm.lvFiles.SetRows IIF(net = 0, PkgFilesRows$(swSession, id), "")
+END SUB
+
+
+' A published version's state as the Published list shows it.
+FUNCTION SwStateText$(BYVAL state AS STRING) AS STRING
+ SELECT CASE state
+ CASE "pending"
+ SwStateText$ = "Waiting for review"
+ CASE "approved"
+ SwStateText$ = "Published"
+ CASE "rejected"
+ SwStateText$ = "Turned down"
+ CASE ELSE
+ SwStateText$ = "Withdrawn"
+ END SELECT
+END FUNCTION
+
+
+' Trust a publisher's key file from now on.
+SUB SwTrustKey
+ DIM path AS STRING
+
+ path = DlgFileOpen$("Trust a Publisher's Key", SW_KEY_FILTER)
+
+ IF path = "" THEN
+ EXIT SUB
+ END IF
+
+ IF PkgTrustKey(swSession, path) THEN
+ MsgBox "Packages signed with this key are trusted from now on.", vbOKOnly + vbInformation, SW_TITLE
+ ELSE
+ SwError "The key cannot be trusted: " & PkgError$(swSession)
+ END IF
+END SUB
+
+
+' A plan row's trust as the list shows it.
+FUNCTION SwTrustText$(BYVAL trust AS STRING) AS STRING
+ SELECT CASE trust
+ CASE "system"
+ SwTrustText$ = "By DVX"
+ CASE "user"
+ SwTrustText$ = "Trusted"
+ CASE ELSE
+ SwTrustText$ = "No"
+ END SELECT
+END FUNCTION
+
+
+' The updates' rows (PkgNetRows$) as the Updates list shows them: name,
+' the version installed, the new one, kind, summary, id; DVX itself
+' first.
+FUNCTION SwUpdateRows$(BYVAL rows AS STRING) AS STRING
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM entry AS STRING
+ DIM text AS STRING
+
+ FOR EACH row IN Split(rows, SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+ entry = cells(1) & SW_TAB & cells(6) & SW_TAB & cells(2) & SW_TAB & cells(3) & SW_TAB & cells(4) & SW_TAB & cells(0) & SW_EOL
+
+ IF cells(0) = SW_DVX_ID THEN
+ text = entry & text
+ ELSE
+ text = text & entry
+ END IF
+ END IF
+ NEXT row
+
+ SwUpdateRows$ = text
+END FUNCTION
diff --git a/src/apps/kpunch/software/software.dbp b/src/apps/kpunch/software/software.dbp
new file mode 100644
index 00000000..4dd9be32
--- /dev/null
+++ b/src/apps/kpunch/software/software.dbp
@@ -0,0 +1,30 @@
+[Project]
+Name = Software
+Author = Scott Duensing
+Publisher = Kangaroo Punch Studios
+Version = 1.00
+Copyright = Copyright 2026 Scott Duensing
+Description = Install, look at, and remove add-on software
+Icon = ICON32.BMP
+
+[Modules]
+File0 = ../../../include/basic/commdlg.bas
+File1 = ../../../include/basic/fileops.bas
+File2 = ../../../include/basic/font.bas
+File3 = ../../../include/basic/help.bas
+File4 = ../../../include/basic/pkg.bas
+File5 = ../../../include/basic/pkgnet.bas
+File6 = ../../../include/basic/shell.bas
+File7 = software.bas
+Count = 8
+
+[Forms]
+File0 = software.frm
+File1 = swPlan.frm
+File2 = swDetail.frm
+File3 = swProg.frm
+File4 = swServer.frm
+Count = 5
+
+[Settings]
+StartupForm = SoftwareForm
diff --git a/src/apps/kpunch/software/software.dhs b/src/apps/kpunch/software/software.dhs
new file mode 100644
index 00000000..3737e32b
--- /dev/null
+++ b/src/apps/kpunch/software/software.dhs
@@ -0,0 +1,197 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+# Software help. Compiled to software.hlp next to software.app; F1 and
+# Help > Contents in the Software window open it.
+
+.topic software.overview
+.title Software
+.toc 0 Software
+.default
+.index Software
+.index Add-ons
+.index Packages
+.index Installing software
+
+.h1 Software
+
+Software adds programs and other things to DVX, shows what has been added, and takes it away again. Each thing you add comes as a package: one file whose name ends in .DPK. A package can hold a program, a library or widget that other programs use, fonts, sounds, themes, desktop pictures, or MIDI instruments. Packages come from a file, or from the package server over the network, which also keeps DVX itself up to date.
+
+Software has four pages: Installed (what is in DVX), Available (what the package server offers), Updates (what the package server has that is newer than what you have), and Published (the packages you have sent to the package server, and how their reviews went).
+
+.link software.install Installing a package
+.link software.available Packages from the package server
+.link software.updates Keeping DVX up to date
+.link software.server Choosing the package server
+.link software.publish Publishing a package
+.link software.remove Removing a package
+.link software.details Details of a package
+.link software.trust Who made a package
+.link software.trouble When an add-on stops DVX from starting
+
+.h2 The Installed List
+
+The Installed list shows every package in DVX, DVX itself included. Each row has the package's name, version, kind, a short description, and its package name (such as acme.chess), which tells two packages with the same name apart. Click a column's heading to sort the list by it.
+
+.topic software.install
+.title Installing a Package
+.toc 1 Installing
+.index Install from File
+.index DPK files
+
+.h1 Installing a Package
+
+Choose Install from File, then the package's .DPK file. Opening a .DPK file in File Manager does the same.
+
+Before anything is installed, Software shows what will happen:
+
+.list
+.item The packages that will be installed. A package often needs others; when they are in the same folder as the one you chose, they are installed with it.
+.item Whether each one is new, an upgrade of a version you have, or the same version again (which repairs it).
+.item Who signed each one (see Who Made a Package). A package nobody you trust signed is marked, and you should install it only if you trust where it came from.
+.item How much disk space it takes.
+.endlist
+
+Choose Install to go ahead, or Cancel. While it installs, Cancel stops it after the package it is working on; a package that was only partly unpacked is left out completely.
+
+New programs appear in Program Manager's Installed Programs. New fonts, sounds, themes, desktop pictures, and MIDI instruments appear in the lists where you choose them. New libraries and widgets work from the next time DVX starts.
+
+.h2 When a Package Cannot Be Installed
+
+Software tells you why, for example:
+
+.list
+.item It needs another package that is not installed and not in the same folder.
+.item It was made for a newer DVX, or for a different version of DVX.
+.item A newer version of it is already installed.
+.item It is an update signed by someone other than whoever signed the version you have.
+.item It would replace a file that belongs to another package.
+.endlist
+
+.topic software.remove
+.title Removing a Package
+.toc 1 Removing
+.index Remove
+.index Uninstall
+
+.h1 Removing a Package
+
+Choose the package in the list, then Remove. If other packages need it, Software names them: they are removed with it, or nothing is removed. Packages that were installed only because the removed ones needed them are offered for removal too.
+
+Files you changed after the package was installed (a data file you edited, say) are left in place, and Software asks whether to delete them as well. Folders the package made are removed when they end up empty.
+
+DVX itself is removed with Setup, not here.
+
+.topic software.details
+.title Details of a Package
+.toc 1 Details
+.index Details
+.index Package details
+
+.h1 Details of a Package
+
+Choose a package and then Details (or double-click it) to see its name, version, kind, publisher, author, licence, web site, who signed it, which packages it needs and which need it, and every file it put in DVX.
+
+For a package on the Available or Updates page, Details shows what the package server says about it: its name, version, kind, publisher, what it needs, and its size. Who signed it and its files are known once it is fetched, and are checked before anything is installed.
+
+.topic software.available
+.title Packages from the Package Server
+.toc 1 The Package Server
+.index Available
+.index Package server
+.index Download
+
+.h1 Packages from the Package Server
+
+The Available page lists every package the package server offers: its name, version, kind, a short description, and the version you have, if any. The list is read from the server the first time you open the page; Refresh reads it again. Type in the Find box to show only the packages whose name or description has those words.
+
+Choose a package and Install. Software fetches it from the server with any packages it needs that you do not have, then shows what will be installed and installs it exactly as for a file (see Installing a Package). A fetch that is stopped, or cut off by the network, goes on from where it stopped the next time.
+
+Every package is checked when it arrives: it must be exactly what the package server listed, and DVX checks who signed it as it does for any package. Nothing the network changes on the way is installed.
+
+.topic software.updates
+.title Keeping DVX Up to Date
+.toc 1 Updates
+.index Updates
+.index Update All
+.index Updating DVX
+
+.h1 Keeping DVX Up to Date
+
+The Updates page asks the package server what it has that is newer than the packages you have, DVX itself included (it is listed first). Check for Updates asks again. Update All fetches and installs everything listed, as Install does on the Available page.
+
+When DVX itself is updated, the new version runs from the next time DVX starts. If some add-ons you have were made for an older DVX and will stop working with the new one, Software names them before anything is installed; update them too, or keep the DVX you have.
+
+.topic software.server
+.title Choosing the Package Server
+.toc 1 The Package Server's Address
+.index Package Server
+.index Server address
+
+.h1 Choosing the Package Server
+
+Software uses DVX's own package server unless you choose another. Choose File > Package Server and type the server's address (its name, such as packages.example.com) and its port; leave both empty to go back to DVX's own. The Available, Updates, and Published pages are read again from the new server.
+
+The same window holds your account on the server, if you have one: the email you registered with and its password. You need it only to publish packages (see Publishing a Package); getting and updating packages needs no account. The password is kept scrambled in DVX's settings file, so it cannot be read at a glance. Scrambled is not locked: someone who copies the file and knows how DVX scrambles it can still read the password, so give it only on a computer you trust.
+
+DVX needs a working network connection to reach the package server (see the network settings in the settings program).
+
+.topic software.publish
+.title Publishing a Package
+.toc 1 Publishing
+.index Publish
+.index Published
+.index Upload
+
+.h1 Publishing a Package
+
+A package you made (with Package Builder, the DVX BASIC IDE's File > Make Package, or the SDK) can be offered to everyone through the package server. You need an account there: register on the server's web site, open the link it mails you, then give the email and password in File > Package Server.
+
+Choose File > Publish a Package (or the Publish a Package button on the Published page) and the package's file. Software sends it to the package server, which checks it as DVX would before installing it and holds it for review. Once the server's operator approves it, it is listed on everyone's Available page, and those who have an older version are offered it on their Updates page.
+
+The Published page lists every version you have sent and its review: waiting for review, published, turned down (with the reviewer's note saying why), or withdrawn from the catalogue. Refresh asks the server again. A package's name belongs to the account that first published it, so later versions must come from the same account, with a higher version number each time.
+
+.topic software.trust
+.title Who Made a Package
+.toc 1 Who Made a Package
+.index Signed packages
+.index Trust
+.index Publisher keys
+
+.h1 Who Made a Package
+
+A publisher can sign a package with a key, a little like a seal on a letter: if the package was changed after it was signed, the seal no longer fits. DVX trusts packages signed by DVX's own key, and by the keys of publishers you choose to trust.
+
+To trust a publisher, get their key file (it ends in .KEY) from them, then choose File > Trust a Publisher's Key. Packages they sign are marked as trusted from then on.
+
+Packages whose names start with kpunch. are part of DVX and must be signed by DVX's own key. An update of a package must be signed by whoever signed the version you have (or by DVX's key), so nobody else can replace it.
+
+.topic software.trouble
+.title When an Add-on Stops DVX from Starting
+.toc 1 Starting Safely
+.index Safe start
+.index /SAFE
+
+.h1 When an Add-on Stops DVX from Starting
+
+If DVX cannot load an add-on library or widget, it starts without it and tells you which one was left out and why, once the desktop is up.
+
+If DVX does not start at all after you installed something, hold down a Shift key while DVX starts (or type DVX /SAFE). DVX then starts without any add-on libraries or widgets, so you can remove the one that causes the trouble with Software.
diff --git a/src/apps/kpunch/software/software.frm b/src/apps/kpunch/software/software.frm
new file mode 100644
index 00000000..760248b6
--- /dev/null
+++ b/src/apps/kpunch/software/software.frm
@@ -0,0 +1,675 @@
+VERSION DVX 1.00
+Begin Form SoftwareForm
+ Caption = "Software"
+ Layout = VBox
+ AutoSize = False
+ Resizable = True
+ Centered = True
+ Width = 580
+ Height = 360
+ Begin Menu mnuFile
+ Caption = "&File"
+ Begin Menu mnuInstall
+ Caption = "&Install from File..."
+ End
+ Begin Menu mnuPublish
+ Caption = "&Publish a Package..."
+ End
+ Begin Menu mnuTrust
+ Caption = "&Trust a Publisher's Key..."
+ End
+ Begin Menu mnuServer
+ Caption = "Package &Server..."
+ End
+ Begin Menu mnuFileSep
+ Caption = "-"
+ End
+ Begin Menu mnuExit
+ Caption = "E&xit"
+ End
+ End
+ Begin Menu mnuPackage
+ Caption = "&Package"
+ Begin Menu mnuDetails
+ Caption = "&Details..."
+ End
+ Begin Menu mnuRemove
+ Caption = "&Remove..."
+ End
+ Begin Menu mnuNetInstall
+ Caption = "&Install"
+ End
+ Begin Menu mnuPackageSep
+ Caption = "-"
+ End
+ Begin Menu mnuCheck
+ Caption = "&Check for Updates"
+ End
+ Begin Menu mnuUpdateAll
+ Caption = "Update A&ll"
+ End
+ End
+ Begin Menu mnuHelp
+ Caption = "&Help"
+ Begin Menu mnuHelpContents
+ Caption = "&Contents"
+ End
+ Begin Menu mnuHelpSep
+ Caption = "-"
+ End
+ Begin Menu mnuAbout
+ Caption = "&About Software..."
+ End
+ End
+ Begin TabStrip tabs
+ Weight = 1
+ Begin TabPage pgInstalled
+ Caption = "I&nstalled"
+ Begin ListView lvInstalled
+ Weight = 1
+ End
+ Begin HBox boxButtons
+ Alignment = Right
+ Begin CommandButton cmdDetails
+ Caption = "&Details..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdRemove
+ Caption = "&Remove..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdInstall
+ Caption = "&Install from File..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ End
+ Begin TabPage pgAvailable
+ Caption = "&Available"
+ Begin HBox boxFind
+ Begin Label lblFind
+ Caption = "Fin&d:"
+ End
+ Begin TextBox txtFind
+ Weight = 1
+ End
+ Begin CommandButton cmdRefresh
+ Caption = "&Refresh"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ Begin ListView lvAvailable
+ Weight = 1
+ End
+ Begin HBox boxNetButtons
+ Alignment = Right
+ Begin CommandButton cmdNetDetails
+ Caption = "&Details..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdNetInstall
+ Caption = "&Install"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ End
+ Begin TabPage pgUpdates
+ Caption = "&Updates"
+ Begin Label lblUpdates
+ Caption = ""
+ End
+ Begin ListView lvUpdates
+ Weight = 1
+ End
+ Begin HBox boxUpdButtons
+ Alignment = Right
+ Begin CommandButton cmdCheck
+ Caption = "&Check for Updates"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdUpdateAll
+ Caption = "Update A&ll"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ End
+ Begin TabPage pgPublished
+ Caption = "Pu&blished"
+ Begin Label lblMine
+ Caption = ""
+ End
+ Begin ListView lvMine
+ Weight = 1
+ End
+ Begin HBox boxMineButtons
+ Alignment = Right
+ Begin CommandButton cmdMineRefresh
+ Caption = "&Refresh"
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdPublish
+ Caption = "&Publish a Package..."
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+ End
+ End
+ Begin StatusBar sbStatus
+ Begin Label lblStatus
+ Caption = ""
+ Weight = 1
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+
+' software.frm -- the Software window: the packages installed in DVX
+' (DVX itself among them), with their details, removing them, and
+' installing a package from a file (also what opening a .DPK file does);
+' the packages the package server offers, installing them; what it has
+' that is newer, DVX itself included, updating them; and, with an account
+' there, publishing packages and how their reviews went. The work is in
+' software.bas.
+
+OPTION EXPLICIT
+
+CONST HELP_FILE = "software.hlp"
+
+' The pages
+CONST SW_PAGE_INSTALLED = 0
+CONST SW_PAGE_AVAILABLE = 1
+CONST SW_PAGE_UPDATES = 2
+CONST SW_PAGE_PUBLISHED = 3
+
+' The lists, each with the package's id last (which the selected row is
+' read back by: a list may sort itself)
+CONST SW_COLUMNS = "Name,15|Version,8|Kind,9|Summary,20|Package,13"
+CONST SW_COL_ID = 4
+CONST SW_NET_COLUMNS = "Name,13|Version,8|Kind,9|Summary,13|Installed,10|Package,12"
+CONST SW_NET_COL_ID = 5
+CONST SW_UPD_COLUMNS = "Name,13|Installed,10|New,8|Kind,9|Summary,13|Package,12"
+CONST SW_UPD_COL_ID = 5
+CONST SW_MINE_COLUMNS = "Name,13|Version,8|State,19|Note,15|Package,12"
+CONST SW_MINE_COL_ID = 4
+
+CONST SW_ABOUT = "Software" & CHR$(10) & CHR$(10) & "Installs, shows, updates, and removes add-on software (programs, libraries, widgets, fonts, sounds, themes, pictures, and instruments) from a file or the package server, and keeps DVX up to date." & CHR$(10) & CHR$(10) & "Copyright 2026 Scott Duensing"
+
+' Whether the lists from the package server have been read since it was
+' chosen
+DIM SHARED swHaveCatalogue AS BOOLEAN
+DIM SHARED swHaveUpdates AS BOOLEAN
+DIM SHARED swHaveMine AS BOOLEAN
+
+
+SUB cmdCheck_Click
+ mnuCheck_Click
+END SUB
+
+
+SUB cmdDetails_Click
+ mnuDetails_Click
+END SUB
+
+
+SUB cmdInstall_Click
+ mnuInstall_Click
+END SUB
+
+
+SUB cmdMineRefresh_Click
+ RefreshMine
+END SUB
+
+
+SUB cmdNetDetails_Click
+ mnuDetails_Click
+END SUB
+
+
+SUB cmdNetInstall_Click
+ mnuNetInstall_Click
+END SUB
+
+
+SUB cmdPublish_Click
+ mnuPublish_Click
+END SUB
+
+
+SUB cmdRefresh_Click
+ RefreshAvailable True
+END SUB
+
+
+SUB cmdRemove_Click
+ mnuRemove_Click
+END SUB
+
+
+SUB cmdUpdateAll_Click
+ mnuUpdateAll_Click
+END SUB
+
+
+SUB lvAvailable_DblClick
+ mnuDetails_Click
+END SUB
+
+
+SUB lvInstalled_DblClick
+ mnuDetails_Click
+END SUB
+
+
+SUB lvMine_DblClick
+ mnuDetails_Click
+END SUB
+
+
+SUB lvUpdates_DblClick
+ mnuDetails_Click
+END SUB
+
+
+SUB mnuAbout_Click
+ MsgBox SW_ABOUT, vbOKOnly + vbInformation, "About Software"
+END SUB
+
+
+SUB mnuCheck_Click
+ tabs.SetActive SW_PAGE_UPDATES
+ RefreshUpdates
+END SUB
+
+
+' The selected package's details: one installed, one the server offers,
+' or an update.
+SUB mnuDetails_Click
+ DIM id AS STRING
+
+ id = SelectedId$()
+
+ IF id = "" THEN
+ EXIT SUB
+ END IF
+
+ SELECT CASE tabs.GetActive()
+ CASE SW_PAGE_INSTALLED
+ SwShowDetails id, 0
+ CASE SW_PAGE_AVAILABLE
+ SwShowDetails id, swNet
+ CASE SW_PAGE_UPDATES
+ SwShowDetails id, swUpd
+ CASE ELSE
+ ' A published version: its review, which is all there is to show
+ MsgBox lvMine.GetCell(lvMine.ListIndex, 0) & " " & lvMine.GetCell(lvMine.ListIndex, 1) & ": " & lvMine.GetCell(lvMine.ListIndex, 2) & IIF(lvMine.GetCell(lvMine.ListIndex, 3) = "", ".", ". " & lvMine.GetCell(lvMine.ListIndex, 3)), vbOKOnly + vbInformation, SW_TITLE
+ EXIT SUB
+ END SELECT
+
+ DetailForm.Show vbModal
+END SUB
+
+
+SUB mnuExit_Click
+ Unload SoftwareForm
+END SUB
+
+
+SUB mnuHelpContents_Click
+ HelpView HELP_FILE
+END SUB
+
+
+SUB mnuInstall_Click
+ DIM path AS STRING
+
+ path = DlgFileOpen$("Install from File", SW_FILTER)
+
+ IF path <> "" THEN
+ SwInstallFile path
+ RefreshAll
+ END IF
+END SUB
+
+
+' Install the package chosen on the Available page.
+SUB mnuNetInstall_Click
+ DIM id AS STRING
+
+ IF tabs.GetActive() <> SW_PAGE_AVAILABLE THEN
+ tabs.SetActive SW_PAGE_AVAILABLE
+ RefreshAvailable False
+ EXIT SUB
+ END IF
+
+ id = SelectedId$()
+
+ IF id <> "" THEN
+ SwFetchInstall swNet, id, PkgNetField$(swNet, id, PKG_FIELD_NAME)
+ RefreshAll
+ END IF
+END SUB
+
+
+' Publish a package file on the package server, with the account the
+' settings give.
+SUB mnuPublish_Click
+ DIM path AS STRING
+
+ IF PkgNetUser$() = "" THEN
+ MsgBox "To publish a package, give your account on the package server in File > Package Server first.", vbOKOnly + vbInformation, SW_TITLE
+ EXIT SUB
+ END IF
+
+ path = DlgFileOpen$("Publish a Package", SW_FILTER)
+
+ IF path <> "" AND SwPublish(path) THEN
+ tabs.SetActive SW_PAGE_PUBLISHED
+ RefreshMine
+ END IF
+END SUB
+
+
+SUB mnuRemove_Click
+ DIM id AS STRING
+
+ IF tabs.GetActive() <> SW_PAGE_INSTALLED THEN
+ tabs.SetActive SW_PAGE_INSTALLED
+ RefreshInstalled
+ EXIT SUB
+ END IF
+
+ id = SelectedId$()
+
+ IF id <> "" THEN
+ SwRemove id
+ RefreshAll
+ END IF
+END SUB
+
+
+' The package server's settings (swServer.frm); after a change, what was
+' read from the server is read again.
+SUB mnuServer_Click
+ swServerChanged = False
+ Load ServerForm
+ ServerForm.Show vbModal
+
+ IF swServerChanged THEN
+ SwReopen
+ swHaveCatalogue = False
+ swHaveUpdates = False
+ swHaveMine = False
+ lvAvailable.SetRows ""
+ lvUpdates.SetRows ""
+ lvMine.SetRows ""
+ lblUpdates.Caption = ""
+ lblMine.Caption = ""
+ tabs_Click
+ END IF
+END SUB
+
+
+SUB mnuTrust_Click
+ SwTrustKey
+END SUB
+
+
+' Update everything on the Updates page.
+SUB mnuUpdateAll_Click
+ DIM ids AS STRING
+ DIM i AS LONG
+
+ IF tabs.GetActive() <> SW_PAGE_UPDATES OR NOT swHaveUpdates THEN
+ mnuCheck_Click
+ EXIT SUB
+ END IF
+
+ FOR i = 0 TO lvUpdates.ListCount - 1
+ ids = ids & lvUpdates.GetCell(i, SW_UPD_COL_ID) & SW_EOL
+ NEXT i
+
+ IF ids = "" THEN
+ MsgBox "Everything is up to date.", vbOKOnly + vbInformation, SW_TITLE
+ EXIT SUB
+ END IF
+
+ SwFetchInstall swUpd, ids, IIF(lvUpdates.ListCount = 1, lvUpdates.GetCell(0, 0), "The updates")
+ RefreshAll
+ RefreshUpdates
+END SUB
+
+
+' Every list after packages came or went: the installed ones, and the
+' installed versions the Available page shows.
+SUB RefreshAll
+ RefreshInstalled
+
+ IF swHaveCatalogue THEN
+ ShowRows lvAvailable, SwAvailableRows$(PkgNetRows$(swNet), txtFind.Text)
+ END IF
+END SUB
+
+
+' The Available page: the catalogue, read from the server the first time
+' (or again when ask is True), shown as the Find box narrows it.
+SUB RefreshAvailable(BYVAL ask AS BOOLEAN)
+ IF ask OR NOT swHaveCatalogue THEN
+ IF NOT SwNetRun(swNet, PkgNetCatalogue(swNet), "Reading the package list") THEN
+ lblStatus.Caption = "The package server could not be reached."
+ EXIT SUB
+ END IF
+
+ swHaveCatalogue = True
+ END IF
+
+ ShowRows lvAvailable, SwAvailableRows$(PkgNetRows$(swNet), txtFind.Text)
+ lblStatus.Caption = CStr(lvAvailable.ListCount) & IIF(lvAvailable.ListCount = 1, " package", " packages") & " on " & PkgNetUrl$()
+
+ IF ask OR txtFind.Text = "" THEN
+ ShellLog "Software: " & lblStatus.Caption
+ END IF
+END SUB
+
+
+' The installed packages, as the list shows them: name, version, kind,
+' summary, id.
+SUB RefreshInstalled
+ DIM row AS VARIANT
+ DIM cells AS VARIANT
+ DIM rows AS STRING
+ DIM count AS LONG
+
+ FOR EACH row IN Split(PkgInstalledRows$(swSession), SW_EOL)
+ IF row <> "" THEN
+ cells = Split(row, SW_TAB)
+ rows = rows & cells(1) & SW_TAB & cells(2) & SW_TAB & cells(3) & SW_TAB & cells(4) & SW_TAB & cells(0) & SW_EOL
+ count = count + 1
+ END IF
+ NEXT row
+
+ ShowRows lvInstalled, rows
+ lblStatus.Caption = CStr(count) & IIF(count = 1, " package installed", " packages installed")
+END SUB
+
+
+' The Published page: what the account has sent, each version with its
+' review, read from the server now.
+SUB RefreshMine
+ IF PkgNetUser$() = "" THEN
+ lblMine.Caption = "Give your account on the package server in File > Package Server to see what you have published."
+ EXIT SUB
+ END IF
+
+ IF NOT SwNetRun(swMine, PkgNetMine(swMine), "Reading your packages") THEN
+ lblMine.Caption = "The package server could not be reached."
+ EXIT SUB
+ END IF
+
+ swHaveMine = True
+ ShowRows lvMine, SwMineRows$(PkgNetMineRows$(swMine))
+ lblMine.Caption = CStr(lvMine.ListCount) & IIF(lvMine.ListCount = 1, " version", " versions") & " published by " & PkgNetUser$()
+ ShellLog "Software: " & lblMine.Caption
+END SUB
+
+
+' The Updates page: what the server has that is newer than what is
+' installed, asked for now.
+SUB RefreshUpdates
+ IF NOT SwNetRun(swUpd, PkgNetUpdates(swUpd), "Checking for updates") THEN
+ lblUpdates.Caption = "The package server could not be reached."
+ EXIT SUB
+ END IF
+
+ swHaveUpdates = True
+ ShowRows lvUpdates, SwUpdateRows$(PkgNetRows$(swUpd))
+
+ IF lvUpdates.ListCount = 0 THEN
+ lblUpdates.Caption = "Everything is up to date."
+ ELSE
+ lblUpdates.Caption = CStr(lvUpdates.ListCount) & IIF(lvUpdates.ListCount = 1, " update is", " updates are") & " ready to install."
+ END IF
+
+ ShellLog "Software: " & lblUpdates.Caption
+END SUB
+
+
+' The id of the package chosen on the page showing, or "" (and a word
+' why).
+FUNCTION SelectedId$() AS STRING
+ SELECT CASE tabs.GetActive()
+ CASE SW_PAGE_INSTALLED
+ IF lvInstalled.ListIndex >= 0 THEN
+ SelectedId$ = lvInstalled.GetCell(lvInstalled.ListIndex, SW_COL_ID)
+ END IF
+ CASE SW_PAGE_AVAILABLE
+ IF lvAvailable.ListIndex >= 0 THEN
+ SelectedId$ = lvAvailable.GetCell(lvAvailable.ListIndex, SW_NET_COL_ID)
+ END IF
+ CASE SW_PAGE_UPDATES
+ IF lvUpdates.ListIndex >= 0 THEN
+ SelectedId$ = lvUpdates.GetCell(lvUpdates.ListIndex, SW_UPD_COL_ID)
+ END IF
+ CASE ELSE
+ IF lvMine.ListIndex >= 0 THEN
+ SelectedId$ = lvMine.GetCell(lvMine.ListIndex, SW_MINE_COL_ID)
+ END IF
+ END SELECT
+
+ IF SelectedId$ = "" THEN
+ MsgBox "Choose a package in the list first.", vbOKOnly + vbInformation, SW_TITLE
+ END IF
+END FUNCTION
+
+
+' Fill a list, its first row chosen (what Details, Install, and Remove
+' act on until another is).
+SUB ShowRows(lv AS Control, BYVAL rows AS STRING)
+ lv.SetRows rows
+
+ IF lv.ListCount > 0 THEN
+ lv.ListIndex = 0
+ END IF
+END SUB
+
+
+SUB SoftwareForm_Load
+ swJob = SW_JOB_NONE
+ swSession = PkgOpen()
+ swNet = PkgNetOpen("")
+ swUpd = PkgNetOpen("")
+ swMine = PkgNetOpen("")
+
+ IF swSession = 0 OR swNet = 0 OR swUpd = 0 OR swMine = 0 THEN
+ SwError "There is not enough memory to start Software."
+ Unload SoftwareForm
+ EXIT SUB
+ END IF
+
+ lvInstalled.SetColumns SW_COLUMNS
+ lvAvailable.SetColumns SW_NET_COLUMNS
+ lvUpdates.SetColumns SW_UPD_COLUMNS
+ lvMine.SetColumns SW_MINE_COLUMNS
+ RefreshInstalled
+
+ ' A package opened from File Manager or Program Manager
+ IF COMMAND$(1) <> "" THEN
+ SwInstallFile COMMAND$(1)
+ RefreshInstalled
+ END IF
+END SUB
+
+
+' Every form of the program is loaded when it starts, and a loaded form
+' keeps it running: the dialogs go too.
+SUB SoftwareForm_Unload
+ Unload PlanForm
+ Unload DetailForm
+ Unload ProgForm
+ Unload ServerForm
+ PkgNetClose swMine
+ PkgNetClose swUpd
+ PkgNetClose swNet
+ PkgClose swSession
+END SUB
+
+
+' A page shown: the server's lists are read the first time they are
+' wanted.
+SUB tabs_Click
+ SELECT CASE tabs.GetActive()
+ CASE SW_PAGE_INSTALLED
+ RefreshInstalled
+ CASE SW_PAGE_AVAILABLE
+ RefreshAvailable False
+ CASE SW_PAGE_UPDATES
+ IF NOT swHaveUpdates THEN
+ RefreshUpdates
+ END IF
+ CASE ELSE
+ IF NOT swHaveMine THEN
+ RefreshMine
+ END IF
+ END SELECT
+END SUB
+
+
+SUB txtFind_Change
+ IF swHaveCatalogue THEN
+ ShowRows lvAvailable, SwAvailableRows$(PkgNetRows$(swNet), txtFind.Text)
+ END IF
+END SUB
diff --git a/src/apps/kpunch/software/swDetail.frm b/src/apps/kpunch/software/swDetail.frm
new file mode 100644
index 00000000..c4ef4bf2
--- /dev/null
+++ b/src/apps/kpunch/software/swDetail.frm
@@ -0,0 +1,64 @@
+VERSION DVX 1.00
+Begin Form DetailForm
+ Caption = "Details"
+ Layout = VBox
+ AutoSize = False
+ Resizable = True
+ Centered = True
+ Width = 460
+ Height = 340
+ Begin ListView lvInfo
+ Weight = 1
+ End
+ Begin Label lblFiles
+ Caption = "Files:"
+ End
+ Begin ListView lvFiles
+ Weight = 1
+ End
+ Begin HBox boxButtons
+ Alignment = Right
+ Begin CommandButton cmdDetailClose
+ Caption = "&Close"
+ Default = True
+ Cancel = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' swDetail.frm -- an installed package's details: what it is, who made
+' and signed it, what it needs and what needs it, and its files (filled
+' in by SwShowDetails, software.bas).
+
+OPTION EXPLICIT
+
+
+SUB cmdDetailClose_Click
+ Unload DetailForm
+END SUB
diff --git a/src/apps/kpunch/software/swPlan.frm b/src/apps/kpunch/software/swPlan.frm
new file mode 100644
index 00000000..5fbaab8d
--- /dev/null
+++ b/src/apps/kpunch/software/swPlan.frm
@@ -0,0 +1,76 @@
+VERSION DVX 1.00
+Begin Form PlanForm
+ Caption = "Install Software"
+ Layout = VBox
+ AutoSize = False
+ Resizable = False
+ Centered = True
+ Width = 460
+ Height = 320
+ Begin Label lblIntro
+ Caption = "This is what will be installed:"
+ End
+ Begin ListView lvPlan
+ Weight = 1
+ End
+ Begin RichLabel rlNotes
+ Width = 440
+ End
+ Begin HBox boxButtons
+ Alignment = Right
+ Begin CommandButton cmdPlanInstall
+ Caption = "&Install"
+ Default = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdPlanCancel
+ Caption = "&Cancel"
+ Cancel = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' swPlan.frm -- the install question: the packages the plan installs
+' (filled in by SwPlanFill, software.bas), with notes on what is needed,
+' who signed it, a restart, and the disk space. Install sets
+' swPlanAccepted; Cancel, Escape, and the close box leave it False.
+
+OPTION EXPLICIT
+
+
+SUB cmdPlanCancel_Click
+ Unload PlanForm
+END SUB
+
+
+SUB cmdPlanInstall_Click
+ swPlanAccepted = True
+ Unload PlanForm
+END SUB
diff --git a/src/apps/kpunch/software/swProg.frm b/src/apps/kpunch/software/swProg.frm
new file mode 100644
index 00000000..1babf605
--- /dev/null
+++ b/src/apps/kpunch/software/swProg.frm
@@ -0,0 +1,72 @@
+VERSION DVX 1.00
+Begin Form ProgForm
+ Caption = "Installing"
+ Layout = VBox
+ AutoSize = False
+ Resizable = False
+ Centered = True
+ Width = 360
+ Height = 130
+ Begin Label lblWhat
+ Caption = ""
+ Weight = 1
+ End
+ Begin ProgressBar pbJob
+ End
+ Begin HBox boxCancel
+ Alignment = Center
+ Begin CommandButton cmdProgCancel
+ Caption = "&Cancel"
+ Cancel = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' swProg.frm -- the progress of an install, a removal, or the package
+' server's job: SwRunJob and SwNetRun (software.bas) run the job and keep
+' this window up to date, and close it when the job ends. Cancel (and
+' the close box) stop the job: an install or removal after the package it
+' is on.
+
+OPTION EXPLICIT
+
+
+SUB cmdProgCancel_Click
+ SwCancel
+END SUB
+
+
+SUB ProgForm_QueryUnload(Cancel AS INTEGER)
+ ' Still running: stop it, and close when it has
+ IF swJob <> SW_JOB_NONE THEN
+ SwCancel
+ Cancel = 1
+ END IF
+END SUB
+
diff --git a/src/apps/kpunch/software/swServer.frm b/src/apps/kpunch/software/swServer.frm
new file mode 100644
index 00000000..c82f7368
--- /dev/null
+++ b/src/apps/kpunch/software/swServer.frm
@@ -0,0 +1,138 @@
+VERSION DVX 1.00
+Begin Form ServerForm
+ Caption = "Package Server"
+ Layout = VBox
+ AutoSize = True
+ Resizable = False
+ Centered = True
+ Begin Label lblServerIntro
+ Caption = "The package server Software gets packages from (leave both empty for DVX's own):"
+ End
+ Begin HBox rowServer
+ Begin Label lblServer
+ Caption = "&Address:"
+ MinWidth = 96
+ End
+ Begin TextBox txtServer
+ MinWidth = 224
+ Weight = 1
+ End
+ End
+ Begin HBox rowPort
+ Begin Label lblPort
+ Caption = "P&ort:"
+ MinWidth = 96
+ End
+ Begin TextBox txtPort
+ MinWidth = 64
+ End
+ End
+ Begin Label lblAccount
+ Caption = "Your account there, to publish packages (leave it empty if you have none):"
+ End
+ Begin HBox rowUser
+ Begin Label lblUser
+ Caption = "&Email:"
+ MinWidth = 96
+ End
+ Begin TextBox txtUser
+ MinWidth = 224
+ Weight = 1
+ End
+ End
+ Begin HBox rowPassword
+ Begin Label lblPassword
+ Caption = "Pass&word:"
+ MinWidth = 96
+ End
+ Begin TextBox txtPassword
+ MinWidth = 224
+ Weight = 1
+ End
+ End
+ Begin HBox boxServerButtons
+ Alignment = Right
+ Begin CommandButton cmdServerOk
+ Caption = "OK"
+ Default = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ Begin CommandButton cmdServerCancel
+ Caption = "&Cancel"
+ Cancel = True
+ MinWidth = 80
+ MinHeight = 24
+ End
+ End
+End
+
+
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+
+' swServer.frm -- the package server's settings (File > Package Server):
+' its address and port (empty: DVX's own), and the account Software
+' publishes with. OK saves them in DVX.INI and leaves swServerChanged
+' True.
+
+OPTION EXPLICIT
+
+CONST SW_PORT_MAX = 65535
+
+
+SUB cmdServerCancel_Click
+ Unload ServerForm
+END SUB
+
+
+SUB cmdServerOk_Click
+ DIM port AS LONG
+
+ ' Empty: the usual port (0 to PkgNetSaveSettings)
+ port = Val(txtPort.Text)
+
+ IF Trim$(txtPort.Text) <> "" AND (port < 1 OR port > SW_PORT_MAX OR Trim$(txtPort.Text) <> CStr(port)) THEN
+ MsgBox "The port is a number from 1 to " & CStr(SW_PORT_MAX) & ", or empty for the usual one.", vbOKOnly + vbExclamation, SW_TITLE
+ txtPort.SetFocus
+ EXIT SUB
+ END IF
+
+ IF NOT PkgNetSaveSettings(Trim$(txtServer.Text), port, Trim$(txtUser.Text), txtPassword.Text) THEN
+ SwError "The package server cannot be saved in DVX's settings."
+ EXIT SUB
+ END IF
+
+ swServerChanged = True
+ Unload ServerForm
+END SUB
+
+
+SUB ServerForm_Load
+ txtServer.Text = PkgNetServer$()
+ txtPort.Text = CStr(PkgNetPort())
+ txtUser.Text = PkgNetUser$()
+ txtPassword.PasswordChar = CHR$(DLG_PASSWORD_MASK)
+ txtPassword.Text = PkgNetPassword$()
+END SUB
diff --git a/src/apps/kpunch/terminal/termPass.frm b/src/apps/kpunch/terminal/termPass.frm
index e6a4c718..d8c63b3d 100644
--- a/src/apps/kpunch/terminal/termPass.frm
+++ b/src/apps/kpunch/terminal/termPass.frm
@@ -66,7 +66,7 @@ FUNCTION PassphraseDialog(pass AS STRING) AS BOOLEAN
Load PassForm
dlgAccepted = False
txtPassphrase.MaxLength = TERM_PASS_MAX
- txtPassphrase.PasswordChar = CHR$(PASSWORD_MASK)
+ txtPassphrase.PasswordChar = CHR$(DLG_PASSWORD_MASK)
txtPassphrase.Text = ""
txtPassphrase.SetFocus
PassForm.Show vbModal
diff --git a/src/apps/kpunch/terminal/termSess.frm b/src/apps/kpunch/terminal/termSess.frm
index 228cbac0..efd81278 100644
--- a/src/apps/kpunch/terminal/termSess.frm
+++ b/src/apps/kpunch/terminal/termSess.frm
@@ -311,7 +311,7 @@ FUNCTION SessionDialog(BYVAL title AS STRING) AS BOOLEAN
txtPrefix.MaxLength = TERM_PREFIX_MAX
txtPhone.MaxLength = TERM_PHONE_MAX
txtPass.MaxLength = TERM_PASS_MAX
- txtPass.PasswordChar = CHR$(PASSWORD_MASK)
+ txtPass.PasswordChar = CHR$(DLG_PASSWORD_MASK)
txtHost.MaxLength = TERM_HOST_MAX
txtAnswer.MaxLength = TERM_ANSWERBACK_MAX
diff --git a/src/apps/kpunch/terminal/termXfer.frm b/src/apps/kpunch/terminal/termXfer.frm
index 8c15a3ec..49512d12 100644
--- a/src/apps/kpunch/terminal/termXfer.frm
+++ b/src/apps/kpunch/terminal/termXfer.frm
@@ -14,6 +14,9 @@ Begin Form XferOptsForm
MinWidth = 224
Weight = 1
End
+ Begin CommandButton cmdDirBrowse
+ Caption = "&Browse..."
+ End
End
Begin HBox rowExists
Begin Label lblExists
@@ -76,11 +79,12 @@ End
' termXfer.frm -- Terminal's Transfer Options dialog: the download
-' folder, what happens to a download that already exists, and the ZMODEM
-' settings (start downloads automatically, ask to continue uploads,
-' escape every control character). OK copies them into the phone book's
-' transfer settings (terminal.bas) and sets dlgAccepted; the caller saves
-' them. An empty folder means the default one.
+' folder (typed, or chosen with Browse...), what happens to a download
+' that already exists, and the ZMODEM settings (start downloads
+' automatically, ask to continue uploads, escape every control
+' character). OK copies them into the phone book's transfer settings
+' (terminal.bas) and sets dlgAccepted; the caller saves them. An empty
+' folder means the default one.
OPTION EXPLICIT
@@ -102,6 +106,25 @@ FUNCTION XferOptionsDialog() AS BOOLEAN
END FUNCTION
+' Browse...: choose the download folder, starting at the one shown (it is
+' made when a download needs it).
+SUB cmdDirBrowse_Click
+ DIM folder AS STRING
+
+ folder = txtDir.Text
+
+ IF folder = "" THEN
+ folder = DefaultDownloadDir$()
+ END IF
+
+ folder = DlgFolder$("Download Folder", folder)
+
+ IF folder <> "" THEN
+ txtDir.Text = folder
+ END IF
+END SUB
+
+
SUB cmdXferCancel_Click
Unload XferOptsForm
END SUB
diff --git a/src/apps/kpunch/terminal/terminal.bas b/src/apps/kpunch/terminal/terminal.bas
index d0dcdfeb..56269b4a 100644
--- a/src/apps/kpunch/terminal/terminal.bas
+++ b/src/apps/kpunch/terminal/terminal.bas
@@ -105,9 +105,6 @@ CONST BYTES_FORMAT = "#,##0"
CONST QUOTE = CHR$(34)
-' A password box shows this character (a small bullet) for each one typed
-CONST PASSWORD_MASK = 249
-
' Color schemes (Settings > Colors)
CONST SCHEME_STANDARD = 0
CONST SCHEME_GREEN = 1
@@ -686,7 +683,7 @@ SUB LoadEntry(BYVAL ini AS LONG, BYVAL sec AS STRING, s AS TermSession)
s.initString = PrefsGet$(ini, sec, KEY_INIT, d.initString)
s.dialPrefix = PrefsGet$(ini, sec, KEY_PREFIX, d.dialPrefix)
s.phone = PrefsGet$(ini, sec, KEY_PHONE, d.phone)
- s.passphrase = PrefsGet$(ini, sec, KEY_PASS, d.passphrase)
+ s.passphrase = PrefsGetSecret$(ini, sec, KEY_PASS)
s.host = PrefsGet$(ini, sec, KEY_HOST, d.host)
s.answerback = PrefsGet$(ini, sec, KEY_ANSWERBACK, d.answerback)
SessionClamp s
@@ -713,7 +710,7 @@ SUB SaveEntry(BYVAL ini AS LONG, BYVAL sec AS STRING, s AS TermSession)
IF s.askPassphrase THEN
PrefsSet ini, sec, KEY_PASS, ""
ELSE
- PrefsSet ini, sec, KEY_PASS, s.passphrase
+ PrefsSetSecret ini, sec, KEY_PASS, s.passphrase
END IF
PrefsSet ini, sec, KEY_HOST, s.host
diff --git a/src/apps/kpunch/terminal/terminal.dhs b/src/apps/kpunch/terminal/terminal.dhs
index 93a217ab..14a51007 100644
--- a/src/apps/kpunch/terminal/terminal.dhs
+++ b/src/apps/kpunch/terminal/terminal.dhs
@@ -113,7 +113,7 @@ Settings > Session Settings... changes the current session. Changes to the conne
Connect over the network The secure link goes over the network to the host name and port on the Network tab (the proxy's own port, usually 2323) instead of through the serial port.
.endtable
-A passphrase typed on this tab is saved in Terminal's settings file as plain text. Choose "Ask for the passphrase when connecting" if other people use this computer.
+A passphrase typed on this tab is saved scrambled in Terminal's settings file, so it cannot be read at a glance, but someone who copies the file and knows how DVX scrambles it can still read it. Choose "Ask for the passphrase when connecting" if other people use this computer.
.h2 Network Tab
@@ -307,7 +307,7 @@ Settings > Transfer Options...:
.table
Setting Meaning
------- -------
- Download folder Where YMODEM and ZMODEM downloads go. It is created if needed.
+ Download folder Where YMODEM and ZMODEM downloads go. Type it, or click Browse... to choose it. It is created if needed.
If a file exists Ask me, Keep both, Continue a partial download, Replace the old file, or Skip the file.
Start ZMODEM downloads automatically Start receiving as soon as the other side starts a ZMODEM send.
Ask the other side to continue interrupted uploads ZMODEM: when you send a file again after an interrupted upload, the other side continues its partial copy. Leave it off unless you are re-sending the same file.
diff --git a/src/common.mk b/src/common.mk
index 3493a3f9..4a9613ec 100644
--- a/src/common.mk
+++ b/src/common.mk
@@ -124,6 +124,11 @@ HOST_LDFLAGS = $(HOST_INSTFLAGS)
HOST_APP_LDFLAGS = -Wl,--wrap=exit -Wl,--wrap=abort
HOST_TOOL_CFLAGS = -O2 $(WARNFLAGS) $(DEPFLAGS)
+# miniz as the package library (src/libs/kpunch/pkg) builds it: inflate,
+# deflate and CRC-32, no ZIP archive API, no stdio or time. SETUP.EXE
+# adds -DMINIZ_NO_DEFLATE_APIS (it only unpacks).
+PKG_MINIZ_FLAGS = -DMINIZ_NO_ARCHIVE_APIS -DMINIZ_NO_STDIO -DMINIZ_NO_TIME -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES
+
# ---- Driver modules (dvxDrv.h) ----
# A library with drivers in drivers// sets OBJDIR and DRVDIR and
# writes $(foreach d,$(DVX_DRIVERS),$(eval $(call DVX_DRIVER_RULE,$d))):
diff --git a/src/include/basic/commdlg.bas b/src/include/basic/commdlg.bas
index c474ff73..ba5ede81 100644
--- a/src/include/basic/commdlg.bas
+++ b/src/include/basic/commdlg.bas
@@ -31,6 +31,7 @@
DECLARE LIBRARY "basrt"
' Show a file Open dialog. Returns selected path, or "" if cancelled.
+ ' title$ "" gives the dialog's usual title (all the file dialogs).
' filter$ specifies file type filters, pipe-delimited:
' "Label (pattern)|Label (pattern)|..."
' The pattern is extracted from inside the parentheses.
@@ -49,6 +50,12 @@ DECLARE LIBRARY "basrt"
DECLARE FUNCTION DlgFileOpenIn$(BYVAL title AS STRING, BYVAL filter AS STRING, BYVAL folder AS STRING) AS STRING
DECLARE FUNCTION DlgFileSaveIn$(BYVAL title AS STRING, BYVAL filter AS STRING, BYVAL folder AS STRING) AS STRING
+ ' Choose a folder, starting at folder$ ("" = the current folder; one
+ ' that does not exist yet opens at the nearest one that does, its name
+ ' typed in). Returns the folder chosen, which may not exist yet (make
+ ' it with FileOpsMakePath), or "" if cancelled.
+ DECLARE FUNCTION DlgFolder$(BYVAL title AS STRING, BYVAL folder AS STRING) AS STRING
+
' Show a modal text input box. Returns entered text, or "" if cancelled.
DECLARE FUNCTION DlgInputBox$(BYVAL title AS STRING, BYVAL prompt AS STRING, BYVAL defaultText AS STRING) AS STRING
@@ -108,3 +115,7 @@ CONST DVX_SAVE_CANCEL = 3
' DlgFont flags: only fixed-pitch families (for a text editor or a terminal)
CONST DLG_FONT_FIXED = 1
+
+' What a password box shows for each character typed (a small bullet):
+' TextBox.PasswordChar = CHR$(DLG_PASSWORD_MASK)
+CONST DLG_PASSWORD_MASK = 249
diff --git a/src/include/basic/fileops.bas b/src/include/basic/fileops.bas
index 81c5797b..10ce25c8 100644
--- a/src/include/basic/fileops.bas
+++ b/src/include/basic/fileops.bas
@@ -72,6 +72,7 @@ CONST FILEOPS_KIND_FONTS = 0
CONST FILEOPS_KIND_SOUNDS = 1
CONST FILEOPS_KIND_THEMES = 2
CONST FILEOPS_KIND_WPAPER = 3
+CONST FILEOPS_KIND_BANKS = 4 ' MIDI instrument banks (OP2)
' FileOpsDriveType kinds
CONST FILEOPS_DRIVE_FIXED = 0 ' a hard disk
diff --git a/src/include/basic/font.bas b/src/include/basic/font.bas
index be256e15..2c051113 100644
--- a/src/include/basic/font.bas
+++ b/src/include/basic/font.bas
@@ -33,7 +33,7 @@
' size you get.
'
' Name a font by family, size, and style on each call. A family name that
-' is not there means the system font. Style is 0 (regular) or the sum of
+' is not there means the system font. Style is FONT_REGULAR or the sum of
' FONT_BOLD and FONT_ITALIC; FONT_UNDERLINE and FONT_STRIKE are lines
' drawn with the text and do not change a font's size.
'
@@ -44,6 +44,7 @@
'
' Usage: Add this file to your project.
+CONST FONT_REGULAR = 0 ' none of the styles
CONST FONT_BOLD = 1
CONST FONT_ITALIC = 2
CONST FONT_UNDERLINE = 4
diff --git a/src/include/basic/http.bas b/src/include/basic/http.bas
new file mode 100644
index 00000000..4ba45b9c
--- /dev/null
+++ b/src/include/basic/http.bas
@@ -0,0 +1,91 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' http.bas -- Web Requests Library for DVX BASIC
+' Asks a web server for a page, a file, or a web service's answer (GET),
+' or sends it something (POST), over the network, a little at a time so
+' the program can show progress and stay responsive.
+' Usage: Add this file to your project.
+'
+' HttpOpen(method, url) makes a request and returns a number ("handle",
+' 0 when out of memory); close it with HttpClose when done (whatever a
+' program leaves open is closed when it ends). Set it up (HttpAddHeader,
+' HttpSetBody, HttpSaveTo, HttpSetTimeout), HttpStart it, then call
+' HttpRunFor until it is HTTP_DONE or HTTP_FAILED:
+'
+' h = HttpOpen("GET", "http://example.com/news.txt")
+' IF HttpStart(h) THEN
+' DO
+' rc = HttpRunFor(h, 60)
+' DoEvents
+' LOOP WHILE rc = HTTP_BUSY
+' END IF
+' IF rc = HTTP_DONE AND HttpStatus(h) = 200 THEN text = HttpBody$(h)
+' HttpClose h
+'
+' HTTP_DONE means the server answered: HttpStatus says how (200 OK, 404
+' not found, ...), and HttpBody$ is what it sent. HTTP_FAILED means no
+' answer came (HttpError$ says why). Only http:// addresses work (no
+' encryption). The numbers below are the library's own and must not
+' change.
+
+' HttpRunFor results
+CONST HTTP_BUSY = 0 ' more to do: call HttpRunFor again
+CONST HTTP_DONE = 1 ' the server answered (see HttpStatus)
+CONST HTTP_FAILED = 2 ' no answer: HttpError$ says why
+
+DECLARE LIBRARY "http"
+ ' A request of method ("GET", "POST", ...) to url: a handle, or 0.
+ DECLARE FUNCTION HttpOpen(BYVAL method AS STRING, BYVAL url AS STRING) AS LONG
+ DECLARE SUB HttpClose(BYVAL h AS LONG)
+
+ ' Before starting: a header to send, a body to send (with its type,
+ ' such as "application/json"), a file to save the reply's body in
+ ' (with keepPart True, a part already there is kept and only the rest
+ ' fetched), and the seconds without progress before giving up (30 to
+ ' start with).
+ DECLARE FUNCTION HttpAddHeader(BYVAL h AS LONG, BYVAL headerName AS STRING, BYVAL value AS STRING) AS LONG
+ DECLARE FUNCTION HttpSetBody(BYVAL h AS LONG, BYVAL body AS BINARY STRING, BYVAL contentType AS STRING) AS LONG
+ DECLARE SUB HttpSaveTo(BYVAL h AS LONG, BYVAL path AS STRING, BYVAL keepPart AS LONG)
+ DECLARE SUB HttpSetTimeout(BYVAL h AS LONG, BYVAL seconds AS LONG)
+
+ ' Start the request: False (HttpError$ says why) for an address that
+ ' is not http://. Then run it for up to milliseconds at a time:
+ ' HTTP_BUSY, HTTP_DONE, or HTTP_FAILED.
+ DECLARE FUNCTION HttpStart(BYVAL h AS LONG) AS LONG
+ DECLARE FUNCTION HttpRunFor(BYVAL h AS LONG, BYVAL milliseconds AS LONG) AS LONG
+
+ ' Progress: bytes so far, and the whole size (-1 when the server has
+ ' not said).
+ DECLARE FUNCTION HttpBytes(BYVAL h AS LONG) AS DOUBLE
+ DECLARE FUNCTION HttpTotal(BYVAL h AS LONG) AS DOUBLE
+
+ ' The answer: its status (200, 404, ...), a header of it, its body
+ ' ("" when it went to a file), the address it came from (after the
+ ' server sent the request elsewhere), and why a request failed.
+ DECLARE FUNCTION HttpStatus(BYVAL h AS LONG) AS LONG
+ DECLARE FUNCTION HttpReplyHeader$(BYVAL h AS LONG, BYVAL headerName AS STRING) AS STRING
+ DECLARE FUNCTION HttpBody$(BYVAL h AS LONG) AS BINARY STRING
+ DECLARE FUNCTION HttpUrl$(BYVAL h AS LONG) AS STRING
+ DECLARE FUNCTION HttpError$(BYVAL h AS LONG) AS STRING
+END DECLARE
diff --git a/src/include/basic/json.bas b/src/include/basic/json.bas
new file mode 100644
index 00000000..dd7c94c1
--- /dev/null
+++ b/src/include/basic/json.bas
@@ -0,0 +1,72 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' json.bas -- JSON Library for DVX BASIC
+' Reads JSON text (a web service's reply, a settings file) and finds
+' things in it by path, and quotes text for JSON of your own.
+' Usage: Add this file to your project.
+'
+' JsonParse(text) reads a document and returns a number ("handle", 0 when
+' the text is not JSON: JsonError$ says why); free it with JsonFree when
+' done (whatever a program leaves open is freed when it ends). The other
+' functions find a value by its path from the top of the document:
+'
+' "name" member name of the top object
+' "packages[3].version" member packages, its item 3 (from 0), then version
+' "[0]" item 0 of a top array
+' "" the top of the document itself
+'
+' A path that leads nowhere gives JSON_NONE, "", and 0. The numbers
+' below are the library's own and must not change.
+
+' JsonType results
+CONST JSON_NONE = 0 ' nothing at that path
+CONST JSON_NULL = 1
+CONST JSON_BOOL = 2
+CONST JSON_NUMBER = 3
+CONST JSON_STRING = 4
+CONST JSON_ARRAY = 5
+CONST JSON_OBJECT = 6
+
+DECLARE LIBRARY "json"
+ ' Read JSON text: a handle, or 0 when it is not JSON (JsonError$ says
+ ' why, with the line it is on).
+ DECLARE FUNCTION JsonParse(BYVAL text AS STRING) AS LONG
+ DECLARE FUNCTION JsonError$() AS STRING
+ DECLARE SUB JsonFree(BYVAL h AS LONG)
+
+ ' What is at path (JSON_*), and its value: a string's text, a
+ ' number, True or False, how many items an array or members an object
+ ' has, and the name of an object's member number index (from 0).
+ DECLARE FUNCTION JsonType(BYVAL h AS LONG, BYVAL path AS STRING) AS LONG
+ DECLARE FUNCTION JsonText$(BYVAL h AS LONG, BYVAL path AS STRING) AS STRING
+ DECLARE FUNCTION JsonNumber(BYVAL h AS LONG, BYVAL path AS STRING) AS DOUBLE
+ DECLARE FUNCTION JsonBool(BYVAL h AS LONG, BYVAL path AS STRING) AS LONG
+ DECLARE FUNCTION JsonCount(BYVAL h AS LONG, BYVAL path AS STRING) AS LONG
+ DECLARE FUNCTION JsonKey$(BYVAL h AS LONG, BYVAL path AS STRING, BYVAL index AS LONG) AS STRING
+
+ ' text as a JSON string, in quotes with its special characters
+ ' escaped: for building JSON to send, such as
+ ' "{" & JsonQuote$("name") & ": " & JsonQuote$(n$) & "}".
+ DECLARE FUNCTION JsonQuote$(BYVAL text AS STRING) AS STRING
+END DECLARE
diff --git a/src/include/basic/pkg.bas b/src/include/basic/pkg.bas
new file mode 100644
index 00000000..9a525647
--- /dev/null
+++ b/src/include/basic/pkg.bas
@@ -0,0 +1,230 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+
+' pkg.bas -- Add-on Package Library for DVX BASIC
+' Lists the packages installed in DVX, reads add-on packages (.DPK files),
+' plans what installing them takes (the packages they need, checked
+' against this DVX, its other packages, and the keys DVX trusts), and
+' installs and removes packages as a job a program runs a little at a
+' time with progress; and makes packages, signs them, and makes keys.
+' Usage: Add this file to your project.
+'
+' A session (PkgOpen) is a number ("handle", 0 = none); close it with
+' PkgClose when done (whatever a program leaves open is closed when it
+' ends). Lists come back as ListView rows (SetRows): the cells of a row
+' separated by a TAB, each row ending in a line feed. A package is named
+' by its id (vendor.name, such as acme.chess); a package read from a file
+' (an "offer") by its number, from 0. The numbers below are the
+' library's own and must not change.
+
+' PkgField$ / PkgOfferField$ fields
+CONST PKG_FIELD_ID = 0
+CONST PKG_FIELD_NAME = 1
+CONST PKG_FIELD_VERSION = 2 ' major.minor.patch
+CONST PKG_FIELD_KIND = 3 ' app, library, widget, font, bank, theme, wallpaper, sound, help, bundle, system
+CONST PKG_FIELD_SUMMARY = 4
+CONST PKG_FIELD_PUBLISHER = 5
+CONST PKG_FIELD_AUTHOR = 6
+CONST PKG_FIELD_LICENCE = 7
+CONST PKG_FIELD_HOMEPAGE = 8
+CONST PKG_FIELD_DVX = 9 ' the oldest DVX it runs on ("" any)
+CONST PKG_FIELD_ABI = 10 ' programs, libraries, widgets: the DVX they were made for
+CONST PKG_FIELD_SIGNER = 11 ' the id of the key that signed it ("" unsigned; for an offer DVX cannot check, the key it names)
+CONST PKG_FIELD_SIGNER_NAME = 12 ' that key's name, when DVX trusts it ("" when it does not)
+CONST PKG_FIELD_EXPLICIT = 13 ' installed: "yes" chosen, "no" only needed by another
+CONST PKG_FIELD_TRUST = 14 ' offer: "system" (DVX's own key), "user" (a key you trust), "none"
+CONST PKG_FIELD_WHERE = 15 ' offer: its file
+CONST PKG_FIELD_SIZE = 16 ' the bytes of its files
+
+' PkgRunFor results
+CONST PKG_RUN_BUSY = 0 ' more to do
+CONST PKG_RUN_DONE = 1 ' finished
+CONST PKG_RUN_FAILED = 2 ' stopped: PkgRunMessage$ says why
+
+DECLARE LIBRARY "pkg"
+ ' ---- Sessions ----
+
+ ' A session of DVX's installed packages and the keys it trusts: a
+ ' handle, or 0 when out of memory.
+ DECLARE FUNCTION PkgOpen() AS LONG
+
+ ' Close a session (a running job stops; one whose files are already in
+ ' place is finished first).
+ DECLARE SUB PkgClose(BYVAL s AS LONG)
+
+ ' Why the last call that failed did.
+ DECLARE FUNCTION PkgError$(BYVAL s AS LONG) AS STRING
+
+ ' ---- What is installed ----
+
+ ' Every installed package (DVX itself is kpunch.dvx).
+ ' Rows: id, name, version, kind, summary.
+ DECLARE FUNCTION PkgInstalledRows$(BYVAL s AS LONG) AS STRING
+
+ ' A field (PKG_FIELD_*) of the installed package id.
+ DECLARE FUNCTION PkgField$(BYVAL s AS LONG, BYVAL id AS STRING, BYVAL field AS LONG) AS STRING
+
+ ' The installed package's files. Rows: name, size.
+ DECLARE FUNCTION PkgFilesRows$(BYVAL s AS LONG, BYVAL id AS STRING) AS STRING
+
+ ' What it needs. Rows: id, the oldest version that will do, the
+ ' version installed ("" when none).
+ DECLARE FUNCTION PkgDependsRows$(BYVAL s AS LONG, BYVAL id AS STRING) AS STRING
+
+ ' The installed packages that need it. Rows: id, name.
+ DECLARE FUNCTION PkgNeededBy$(BYVAL s AS LONG, BYVAL id AS STRING) AS STRING
+
+ ' ---- Packages from files ----
+
+ ' Read the package file path, and every other package in its folder
+ ' (what it may need), as the offers (the ones before are gone).
+ ' Returns path's offer number (0), or -1: PkgError$ says why.
+ DECLARE FUNCTION PkgOfferFile(BYVAL s AS LONG, BYVAL path AS STRING) AS LONG
+
+ ' Read every package file in folder as the offers (the ones before
+ ' are gone; one that cannot be read is left out): how many, or -1
+ ' while a job runs. What pkgnet.bas fetched is planned this way.
+ DECLARE FUNCTION PkgOfferFolder(BYVAL s AS LONG, BYVAL folder AS STRING) AS LONG
+
+ ' How many offers there are, and the number of the newest offer of
+ ' the package id (-1 when none is).
+ DECLARE FUNCTION PkgOfferCount(BYVAL s AS LONG) AS LONG
+ DECLARE FUNCTION PkgOfferIndex(BYVAL s AS LONG, BYVAL id AS STRING) AS LONG
+
+ ' A field (PKG_FIELD_*) of an offer, its files, and what it needs (as
+ ' PkgFilesRows$ and PkgDependsRows$).
+ DECLARE FUNCTION PkgOfferField$(BYVAL s AS LONG, BYVAL offer AS LONG, BYVAL field AS LONG) AS STRING
+ DECLARE FUNCTION PkgOfferFilesRows$(BYVAL s AS LONG, BYVAL offer AS LONG) AS STRING
+ DECLARE FUNCTION PkgOfferDependsRows$(BYVAL s AS LONG, BYVAL offer AS LONG) AS STRING
+
+ ' Trust the key file keyPath (NAME.KEY) from now on. Read the offers
+ ' again (PkgOfferFile) to see their new trust.
+ DECLARE FUNCTION PkgTrustKey(BYVAL s AS LONG, BYVAL keyPath AS STRING) AS LONG
+
+ ' ---- Planning ----
+
+ ' Plan installing the offers numbered in offers ("0" or "0 3") with
+ ' everything they need. True, or False with the reason in PkgError$
+ ' (something missing, made for another DVX, a newer version installed,
+ ' signed by someone else than the installed one, ...).
+ DECLARE FUNCTION PkgPlan(BYVAL s AS LONG, BYVAL offers AS STRING) AS LONG
+
+ ' The plan, in the order it installs. Rows: id, name, version, action
+ ' (install, upgrade, reinstall), chosen ("yes"; "no": needed by one
+ ' that is), trust (system, user, none: warn before installing).
+ DECLARE FUNCTION PkgPlanRows$(BYVAL s AS LONG) AS STRING
+
+ ' The bytes the plan unpacks.
+ DECLARE FUNCTION PkgPlanBytes(BYVAL s AS LONG) AS DOUBLE
+
+ ' True when the plan installs a library, a widget, or DVX itself: they
+ ' work from the next time DVX starts.
+ DECLARE FUNCTION PkgPlanRestart(BYVAL s AS LONG) AS LONG
+
+ ' The installed add-ons an update of DVX in the plan leaves unable to
+ ' load (made for the old DVX). Rows: id, name.
+ DECLARE FUNCTION PkgPlanStale$(BYVAL s AS LONG) AS STRING
+
+ ' Removing: the installed packages that need any of ids (one per
+ ' line), which must go too, first to go first; and those only ids
+ ' needed that nobody chose, which may go too. Rows: id, name.
+ DECLARE FUNCTION PkgDependents$(BYVAL s AS LONG, BYVAL ids AS STRING) AS STRING
+ DECLARE FUNCTION PkgOrphans$(BYVAL s AS LONG, BYVAL ids AS STRING) AS STRING
+
+ ' ---- Jobs ----
+
+ ' Start installing the plan, or removing the installed packages ids
+ ' (one per line, in that order; files changed since they were
+ ' installed stay unless removeChanged). False when a job is running
+ ' (or, installing, there is no plan).
+ DECLARE FUNCTION PkgInstallStart(BYVAL s AS LONG) AS LONG
+ DECLARE FUNCTION PkgRemoveStart(BYVAL s AS LONG, BYVAL ids AS STRING, BYVAL removeChanged AS LONG) AS LONG
+
+ ' Run the job for up to milliseconds: PKG_RUN_BUSY, PKG_RUN_DONE, or
+ ' PKG_RUN_FAILED. Call it again (from a Timer, say) while BUSY.
+ DECLARE FUNCTION PkgRunFor(BYVAL s AS LONG, BYVAL milliseconds AS LONG) AS LONG
+
+ ' Progress: percent done (0-100) and the package it is on.
+ DECLARE FUNCTION PkgRunPercent(BYVAL s AS LONG) AS LONG
+ DECLARE FUNCTION PkgRunCurrent$(BYVAL s AS LONG) AS STRING
+
+ ' Why the job failed.
+ DECLARE FUNCTION PkgRunMessage$(BYVAL s AS LONG) AS STRING
+
+ ' The files the job left in place because they changed since they
+ ' were installed, one per line.
+ DECLARE FUNCTION PkgRunKept$(BYVAL s AS LONG) AS STRING
+
+ ' Stop the job after the package it is on (one still being unpacked
+ ' is left as it was).
+ DECLARE SUB PkgRunCancel(BYVAL s AS LONG)
+
+ ' ---- Making packages ----
+
+ ' The kinds of package by number from 0 ("app", "library", ...; "" past
+ ' the last), and the folder a package of a kind keeps its files in
+ ' (APPS\VENDOR\NAME for a program; "" for bundle and system, or a bad
+ ' kind or id).
+ DECLARE FUNCTION PkgKindName$(BYVAL kind AS LONG) AS STRING
+ DECLARE FUNCTION PkgKindFolder$(BYVAL kind AS STRING, BYVAL id AS STRING) AS STRING
+
+ ' True when id is a package id (vendor.name: each part 1 to 8 of a-z,
+ ' 0-9, and _), and when text is a version (1, 1.2, or 1.2.0); and a
+ ' part of an id made from text such as a publisher's name ("Acme
+ ' Games" gives "acmegame").
+ DECLARE FUNCTION PkgIdValid(BYVAL id AS STRING) AS LONG
+ DECLARE FUNCTION PkgIdPart$(BYVAL text AS STRING) AS STRING
+ DECLARE FUNCTION PkgVersionValid(BYVAL text AS STRING) AS LONG
+
+ ' This DVX's version (a package's oldest DVX) and the ABI its programs,
+ ' libraries, and widgets are made for (a package's abi).
+ DECLARE FUNCTION PkgDvxVersion$() AS STRING
+ DECLARE FUNCTION PkgDvxAbi() AS LONG
+
+ ' Every file under folder as the files for PkgBuild: rows of its name
+ ' in the package (prefix, then its name under folder; "" names it from
+ ' folder, laid out like the DVX folder) and the file, in name order.
+ ' "" with the reason in PkgError$ when folder cannot be read.
+ DECLARE FUNCTION PkgFolderRows$(BYVAL s AS LONG, BYVAL folder AS STRING, BYVAL prefix AS STRING) AS STRING
+
+ ' Build the package outPath from templatePath (a PACKAGE.INI without
+ ' [files] or format; other sections are ignored; a program, library,
+ ' or widget with no abi gets PkgDvxAbi's) and files (rows of a name
+ ' in the package TAB the file it is made from), signed with the secret
+ ' key file secretPath unless it is "". True, or False: PkgError$ says
+ ' why.
+ DECLARE FUNCTION PkgBuild(BYVAL s AS LONG, BYVAL templatePath AS STRING, BYVAL files AS STRING, BYVAL outPath AS STRING, BYVAL secretPath AS STRING) AS LONG
+
+ ' Sign the package dpkPath with the secret key file secretPath: into it,
+ ' or beside it (NAME.SIG) when detached.
+ DECLARE FUNCTION PkgSign(BYVAL s AS LONG, BYVAL dpkPath AS STRING, BYVAL secretPath AS STRING, BYVAL detached AS LONG) AS LONG
+
+ ' Make a key pair called keyName: basePath.KEY, the public key to give out
+ ' (and to trust: PkgTrustKey), and basePath.SEC, the secret key to sign
+ ' with and keep private.
+ DECLARE FUNCTION PkgKeygen(BYVAL s AS LONG, BYVAL keyName AS STRING, BYVAL basePath AS STRING) AS LONG
+
+ ' The name and id of the key in a key file (.KEY) or secret key file
+ ' (.SEC) as a row; "" with the reason in PkgError$ when it is neither.
+ DECLARE FUNCTION PkgKeyInfo$(BYVAL s AS LONG, BYVAL path AS STRING) AS STRING
+END DECLARE
diff --git a/src/include/basic/pkgnet.bas b/src/include/basic/pkgnet.bas
new file mode 100644
index 00000000..cab64831
--- /dev/null
+++ b/src/include/basic/pkgnet.bas
@@ -0,0 +1,132 @@
+' The MIT License (MIT)
+'
+' Copyright (C) 2026 Scott Duensing
+'
+' Permission is hereby granted, free of charge, to any person obtaining a copy
+' of this software and associated documentation files (the "Software"), to
+' deal in the Software without restriction, including without limitation the
+' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+' sell copies of the Software, and to permit persons to whom the Software is
+' furnished to do so, subject to the following conditions:
+'
+' The above copyright notice and this permission notice shall be included in
+' all copies or substantial portions of the Software.
+'
+' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+' IN THE SOFTWARE.
+
+' pkgnet.bas -- Package Server Library for DVX BASIC
+' Asks a DVX package server what it offers (its catalogue) and what it has
+' that is newer than the packages installed (DVX itself among them), and
+' fetches packages, with what they need that is not installed, into a
+' download folder. With an account on the server, it also publishes
+' packages there and lists what the account has sent. There pkg.bas reads them (PkgOfferFolder), plans
+' installing them, and installs them as it does a package file. Every
+' package fetched is checked against the size and fingerprint (SHA-256)
+' the server gives, and the server's signature of it is fetched beside
+' it, so whether DVX trusts it is decided as for any package.
+' Usage: Add this file and pkg.bas to your project.
+'
+' PkgNetOpen("") gives a number ("handle", 0 = none) on the server DVX.INI
+' names, with its account; close it with PkgNetClose when done (whatever a
+' program leaves open is closed when it ends). Start a job, then call
+' PkgNetRunFor until it is PKGNET_DONE or PKGNET_FAILED:
+'
+' n = PkgNetOpen("")
+' IF PkgNetCatalogue(n) THEN
+' DO
+' rc = PkgNetRunFor(n, 60)
+' DoEvents
+' LOOP WHILE rc = PKGNET_BUSY
+' END IF
+' IF rc = PKGNET_DONE THEN lvList.SetRows PkgNetRows$(n)
+'
+' Lists come back as ListView rows (SetRows): the cells of a row separated
+' by a TAB, each row ending in a line feed. The numbers below are the
+' library's own and must not change.
+
+' PkgNetRunFor results
+CONST PKGNET_BUSY = 0 ' more to do: call PkgNetRunFor again
+CONST PKGNET_DONE = 1 ' finished
+CONST PKGNET_FAILED = 2 ' stopped: PkgNetError$ says why
+
+DECLARE LIBRARY "pkgnet"
+ ' A handle on the package server at server (an address such as
+ ' "http://host:port", or "" for the one DVX.INI names, with its
+ ' account), fetching into the user folder's DOWNLOAD: 0 when out of
+ ' memory. An account for a handle on another server:
+ ' PkgNetAccount(n, email, password).
+ DECLARE FUNCTION PkgNetOpen(BYVAL server AS STRING) AS LONG
+ DECLARE SUB PkgNetClose(BYVAL n AS LONG)
+ DECLARE SUB PkgNetAccount(BYVAL n AS LONG, BYVAL user AS STRING, BYVAL password AS STRING)
+
+ ' The package server DVX.INI names ([packages]): its host name, its
+ ' port, and the account to publish with (email and password, "" for
+ ' none). PkgNetSaveSettings saves all four (a server of "" is DVX's
+ ' own, a port of 0 the usual 80): False when DVX.INI cannot be
+ ' written.
+ DECLARE FUNCTION PkgNetServer$() AS STRING
+ DECLARE FUNCTION PkgNetPort() AS LONG
+ DECLARE FUNCTION PkgNetUser$() AS STRING
+ DECLARE FUNCTION PkgNetPassword$() AS STRING
+ DECLARE FUNCTION PkgNetSaveSettings(BYVAL server AS STRING, BYVAL port AS LONG, BYVAL user AS STRING, BYVAL password AS STRING) AS LONG
+
+ ' The server's address as those settings make it: "http://host", with
+ ' ":port" when the port is not the usual one.
+ DECLARE FUNCTION PkgNetUrl$() AS STRING
+
+ ' Start a job (False when one is running): read the catalogue (every
+ ' package the server offers, at its newest version); ask what is
+ ' newer than what is installed; or fetch packages (ids separated by
+ ' line ends, commas, or spaces) with what they need.
+ DECLARE FUNCTION PkgNetCatalogue(BYVAL n AS LONG) AS LONG
+ DECLARE FUNCTION PkgNetUpdates(BYVAL n AS LONG) AS LONG
+ DECLARE FUNCTION PkgNetFetch(BYVAL n AS LONG, BYVAL ids AS STRING) AS LONG
+
+ ' With the handle's account (signing in first): publish the package
+ ' file at path (the server's operator reviews it before it is
+ ' offered), or list what the account has sent.
+ DECLARE FUNCTION PkgNetUpload(BYVAL n AS LONG, BYVAL path AS STRING) AS LONG
+ DECLARE FUNCTION PkgNetMine(BYVAL n AS LONG) AS LONG
+
+ ' Run the job for up to milliseconds: PKGNET_BUSY, PKGNET_DONE, or
+ ' PKGNET_FAILED. Stop it (a download stopped part way is kept, and
+ ' goes on from there the next time).
+ DECLARE FUNCTION PkgNetRunFor(BYVAL n AS LONG, BYVAL milliseconds AS LONG) AS LONG
+ DECLARE SUB PkgNetCancel(BYVAL n AS LONG)
+
+ ' How far a fetch is (0 to 100), the package it is on (its name), and
+ ' why a job failed ("" when it has not).
+ DECLARE FUNCTION PkgNetPercent(BYVAL n AS LONG) AS LONG
+ DECLARE FUNCTION PkgNetCurrent$(BYVAL n AS LONG) AS STRING
+ DECLARE FUNCTION PkgNetError$(BYVAL n AS LONG) AS STRING
+
+ ' The answer: the catalogue (after reading it or fetching) or the
+ ' updates. Rows: id, name, version, kind, summary, size (bytes), the
+ ' version installed ("" when it is not).
+ DECLARE FUNCTION PkgNetRows$(BYVAL n AS LONG) AS STRING
+
+ ' What the account has sent (after PkgNetMine). Rows: id, name,
+ ' version, state ("pending" for review, "approved", "rejected", or
+ ' "withdrawn" from the catalogue), the review's note.
+ DECLARE FUNCTION PkgNetMineRows$(BYVAL n AS LONG) AS STRING
+
+ ' One field of a package in the answer: pkg.bas's PKG_FIELD_* (who
+ ' signed it, the trust, and the file are not known until it is
+ ' fetched: ""; PKG_FIELD_SIZE is its package file's bytes). What it
+ ' needs: rows of id, the oldest version that will do, the version
+ ' installed ("" when it is not).
+ DECLARE FUNCTION PkgNetField$(BYVAL n AS LONG, BYVAL id AS STRING, BYVAL field AS LONG) AS STRING
+ DECLARE FUNCTION PkgNetDependsRows$(BYVAL n AS LONG, BYVAL id AS STRING) AS STRING
+
+ ' The download folder (for pkg.bas's PkgOfferFolder after a fetch),
+ ' and removing what the last fetch put there (once it is installed,
+ ' or not wanted).
+ DECLARE FUNCTION PkgNetDir$(BYVAL n AS LONG) AS STRING
+ DECLARE SUB PkgNetClear(BYVAL n AS LONG)
+END DECLARE
diff --git a/src/include/basic/prefs.bas b/src/include/basic/prefs.bas
index f32314f7..cd22f105 100644
--- a/src/include/basic/prefs.bas
+++ b/src/include/basic/prefs.bas
@@ -87,6 +87,14 @@ DECLARE LIBRARY "libdvx"
' Stored as "true" or "false".
DECLARE SUB PrefsSetBool(BYVAL handle AS LONG, BYVAL section AS STRING, BYVAL key AS STRING, BYVAL value AS LONG)
+ ' A secret such as a password, kept obfuscated: "*" and hex digits that
+ ' change each time it is saved, so it cannot be read at a glance (not
+ ' encryption: a program can read it back, which is the point).
+ ' PrefsGetSecret$ reads it back; a value typed into the file by hand is
+ ' read as it is.
+ DECLARE SUB PrefsSetSecret(BYVAL handle AS LONG, BYVAL section AS STRING, BYVAL key AS STRING, BYVAL value AS STRING)
+ DECLARE FUNCTION PrefsGetSecret$(BYVAL handle AS LONG, BYVAL section AS STRING, BYVAL key AS STRING) AS STRING
+
' Remove key from [section].
DECLARE SUB PrefsRemove(BYVAL handle AS LONG, BYVAL section AS STRING, BYVAL key AS STRING)
diff --git a/src/installer/Makefile b/src/installer/Makefile
index 69f3439c..07a227cb 100644
--- a/src/installer/Makefile
+++ b/src/installer/Makefile
@@ -24,7 +24,8 @@
#
# bin/setup.exe SETUP.EXE: one static DOS program -- the DVX core,
# the widgets Setup uses, fileops, miniz's inflate, the
-# Setup program, and its fonts (setupFonts.ini) --
+# Setup program, its fonts (setupFonts.ini), and its
+# start page's icons (mkicon) --
# linked with --gc-sections so only what Setup reaches
# is in it, then packed with UPX (it unpacks itself in
# memory when it starts). Also shipped in the package
@@ -42,7 +43,9 @@ include ../common.mk
LIBDVX = ../libs/kpunch/libdvx
KPUNCH = ../libs/kpunch
WIDGETS = ../widgets/kpunch
-MINIZ = thirdparty/miniz
+PKGLIB = $(KPUNCH)/pkg
+MINIZ = $(PKGLIB)/thirdparty/miniz
+MONO = $(PKGLIB)/thirdparty/monocypher/src
LICENSE = ../../LICENSE.txt
OBJDIR = ../../obj/installer
@@ -57,22 +60,29 @@ FONTS_H = $(OBJDIR)/setupFonts.h
FONTS_INI = setupFonts.ini
FONTS_DIR = $(OBJDIR)/fonts
MKFONT = $(HOSTBIN)/mkfont
+ICONS_H = $(OBJDIR)/setupIcons.h
+ICONS_DIR = $(OBJDIR)/icons
+MKICON = $(HOSTBIN)/mkicon
+# The start page's icons: mkicon types, each sIcon in setupIcons.h
+SETUP_ICONS = install upgrade remove
# NRV2E: about half the size, and it unpacks in a fraction of a second on
# a 486 (LZMA saves 15 KB more but unpacks several times slower). UPX
# keeps the CWSDPMI stub.
UPX_FLAGS = -qq --best --nrv2e
-INCLUDES = -I. -I$(MINIZ) -I$(OBJDIR) -I$(LIBDVX) -I$(LIBDVX)/platform -I$(LIBDVX)/thirdparty -I$(KPUNCH) -I$(KPUNCH)/libtasks -I$(WIDGETS)
-MINIZ_DOS = -DMINIZ_NO_ARCHIVE_APIS -DMINIZ_NO_STDIO -DMINIZ_NO_TIME -DMINIZ_NO_DEFLATE_APIS -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES
+INCLUDES = -I. -I$(PKGLIB) -I$(MINIZ) -I$(OBJDIR) -I$(LIBDVX) -I$(LIBDVX)/platform -I$(LIBDVX)/thirdparty -I$(KPUNCH) -I$(KPUNCH)/libtasks -I$(WIDGETS)
+MINIZ_DOS = $(PKG_MINIZ_FLAGS) -DMINIZ_NO_DEFLATE_APIS
CFLAGS = $(DOS_CFLAGS) -ffunction-sections -fdata-sections $(INCLUDES) $(MINIZ_DOS)
LDFLAGS = -s -Wl,--gc-sections -lm
-# Setup's own sources; the core, fileops and text help as they are
-SETUP_SRCS = setup.c setupCore.c pkgRead.c pkgFormat.c $(MINIZ)/miniz.c
-CORE_SRCS = $(wildcard $(LIBDVX)/*.c) $(LIBDVX)/platform/dvxPlatformDos.c $(LIBDVX)/platform/dvxPlatformIrq.c $(LIBDVX)/platform/dvxPlatformUtil.c $(LIBDVX)/platform/dvxMemTrack.c $(LIBDVX)/thirdparty/stb_ds_impl.c ../tools/dvxResWrite.c $(wildcard $(KPUNCH)/fileops/*.c) $(KPUNCH)/texthelp/textHelp.c
+# Setup's own sources; the core, fileops, and the text and list helpers
+# as they are
+SETUP_SRCS = setup.c setupCore.c pkgRead.c $(PKGLIB)/pkgDb.c $(PKGLIB)/pkgFormat.c $(PKGLIB)/pkgIni.c $(PKGLIB)/pkgManifest.c $(PKGLIB)/pkgZip.c $(MINIZ)/miniz.c
+CORE_SRCS = $(wildcard $(LIBDVX)/*.c) $(LIBDVX)/platform/dvxPlatformDos.c $(LIBDVX)/platform/dvxPlatformIrq.c $(LIBDVX)/platform/dvxPlatformUtil.c $(LIBDVX)/platform/dvxMemTrack.c $(LIBDVX)/thirdparty/stb_ds_impl.c ../tools/dvxResWrite.c $(wildcard $(KPUNCH)/fileops/*.c) $(KPUNCH)/texthelp/textHelp.c $(KPUNCH)/listhelp/listHelp.c
-# The widgets Setup uses: /: (wgtRegister -> wgtRegister)
-SETUP_WGTS = box/widgetBox:Box button/widgetButton:Button checkbox/widgetCheckbox:Checkbox label/widgetLabel:Label progressBar/widgetProgressBar:Progress radio/widgetRadio:Radio spacer/widgetSpacer:Spacer textInput/widgetTextInput:TextInput
+# The widgets Setup uses: /: (wgtRegister -> wgtRegister);
+# the list box and the dropdown are the folder dialog's
+SETUP_WGTS = box/widgetBox:Box button/widgetButton:Button checkbox/widgetCheckbox:Checkbox dropdown/widgetDropdown:Dropdown imageButton/widgetImageButton:ImageButton label/widgetLabel:Label listBox/widgetListBox:ListBox progressBar/widgetProgressBar:Progress radio/widgetRadio:Radio spacer/widgetSpacer:Spacer textInput/widgetTextInput:TextInput
SETUP_OBJS = $(patsubst %.c,$(DOSOBJ)/%.o,$(notdir $(SETUP_SRCS)))
CORE_OBJS = $(patsubst %.c,$(DOSOBJ)/core/%.o,$(notdir $(CORE_SRCS)))
@@ -80,11 +90,11 @@ WGT_OBJS = $(foreach w,$(SETUP_WGTS),$(DOSOBJ)/wgt/$(notdir $(firstword $(subs
CORE_LIB = $(DOSOBJ)/setupCore.a
# The package tool (host)
-TOOL_SRCS = pkgTool.c pkgBuild.c pkgFormat.c $(MINIZ)/miniz.c
+TOOL_SRCS = pkgTool.c pkgBuild.c $(PKGLIB)/pkgFormat.c $(PKGLIB)/pkgCli.c $(PKGLIB)/pkgDb.c $(PKGLIB)/pkgDpk.c $(PKGLIB)/pkgIni.c $(PKGLIB)/pkgManifest.c $(PKGLIB)/pkgOp.c $(PKGLIB)/pkgPlan.c $(PKGLIB)/pkgSign.c $(PKGLIB)/pkgTrust.c $(PKGLIB)/pkgZip.c $(PKGLIB)/pkgZipWrite.c $(MINIZ)/miniz.c $(MONO)/monocypher.c $(MONO)/optional/monocypher-ed25519.c $(KPUNCH)/serial/security/secHash.c $(KPUNCH)/serial/security/security.c $(LIBDVX)/platform/dvxPlatformUtil.c $(LIBDVX)/thirdparty/stb_ds_impl.c
TOOL_OBJS = $(patsubst %.c,$(HOSTOBJ)/%.o,$(notdir $(TOOL_SRCS)))
-TOOL_FLAGS = $(HOST_TOOL_CFLAGS) -I. -I$(MINIZ)
+TOOL_FLAGS = $(HOST_TOOL_CFLAGS) -I. -I$(PKGLIB) -I$(MINIZ) -I$(MONO) -I$(KPUNCH) -I$(LIBDVX) -I$(LIBDVX)/platform -I$(LIBDVX)/thirdparty
-vpath %.c . $(MINIZ)
+vpath %.c . $(PKGLIB) $(MINIZ) $(MONO) $(MONO)/optional $(KPUNCH)/serial/security $(LIBDVX)/platform $(LIBDVX)/thirdparty
.PHONY: all clean
@@ -105,7 +115,7 @@ $(CORE_LIB): $(CORE_OBJS) $(WGT_OBJS)
rm -f $@
$(DJGPP_PREFIX)/bin/$(DJGPP_TRIPLE)-ar rcs $@ $^
-$(DOSOBJ)/%.o: %.c $(DVX_MAKEFILES) | $(LICENSE_H) $(FONTS_H)
+$(DOSOBJ)/%.o: %.c $(DVX_MAKEFILES) | $(LICENSE_H) $(FONTS_H) $(ICONS_H)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c -o $@ $<
@@ -136,6 +146,9 @@ $(LICENSE_H): $(LICENSE) $(DVX_MAKEFILES)
tr -d '\r' < $< | iconv -f UTF-8 -t ASCII//TRANSLIT | fmt -w $(LICENSE_COLS) | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/^/ "/' -e 's/$$/\\n"/'; \
echo ';'; } > $@.tmp && mv $@.tmp $@
+# The bytes of file $(1) as the lines of a C array (for the headers below)
+BYTES_C = od -An -v -tx1 $(1) | sed -e 's/ */ /g' -e 's/^ //' -e 's/ $$//' -e 's/ /, 0x/g' -e 's/^/ 0x/' -e 's/$$/,/'
+
# The fonts Setup carries (setupFonts.ini), made by mkfont and written as
# C arrays, one per .DFN, and the table setup.c reads (sSetupFonts, of
# SetupFontDataT: name, bytes, and length)
@@ -148,7 +161,7 @@ $(FONTS_H): $(FONTS_INI) $(MKFONT) $(FONT_SOURCES)/.installed $(DVX_MAKEFILES)
for f in $(FONTS_DIR)/*.DFN; do \
n=$$(basename $$f .DFN); \
echo "static const uint8_t sFont$$n[] = {"; \
- od -An -v -tx1 $$f | sed -e 's/ */ /g' -e 's/^ //' -e 's/ $$//' -e 's/ /, 0x/g' -e 's/^/ 0x/' -e 's/$$/,/'; \
+ $(call BYTES_C,$$f); \
echo '};'; \
done; \
echo 'static const SetupFontDataT sSetupFonts[] = {'; \
@@ -158,6 +171,20 @@ $(FONTS_H): $(FONTS_INI) $(MKFONT) $(FONT_SOURCES)/.installed $(DVX_MAKEFILES)
done; \
echo '};'; } > $@.tmp && mv $@.tmp $@
+# The start page's icons (SETUP_ICONS), made by mkicon and written as C
+# arrays of their BMP files: sIconInstall and so on
+$(ICONS_H): $(MKICON) $(DVX_MAKEFILES)
+ rm -rf $(ICONS_DIR)
+ mkdir -p $(ICONS_DIR)
+ for t in $(SETUP_ICONS); do $(MKICON) $(ICONS_DIR)/$$t.bmp $$t > /dev/null || exit 1; done
+ { echo '// Generated by src/installer/Makefile from mkicon (SETUP_ICONS)'; \
+ echo '#include '; \
+ for t in $(SETUP_ICONS); do \
+ echo "static const uint8_t sIcon$$(echo $$t | sed 's/^./\u&/')[] = {"; \
+ $(call BYTES_C,$(ICONS_DIR)/$$t.bmp); \
+ echo '};'; \
+ done; } > $@.tmp && mv $@.tmp $@
+
$(TOOL): $(TOOL_OBJS) | $(HOSTBIN)
$(HOSTCC) -o $@ $(TOOL_OBJS)
diff --git a/src/installer/README.TXT b/src/installer/README.TXT
index 0ea31829..61a8b86d 100644
--- a/src/installer/README.TXT
+++ b/src/installer/README.TXT
@@ -32,12 +32,13 @@ From floppy disks: put Disk 1 in drive A: and type
A:
SETUP
-Setup shows the licence, asks for the folder to install DVX in (C:\DVX
-unless you change it) and which fonts to install, checks the free
-space, and unpacks DVX. With floppies it asks for each disk it needs in
-turn. Setup can also add a line to AUTOEXEC.BAT so DVX starts with the
-computer; it shows the line first and keeps a copy of the old file as
-AUTOEXEC.DVX.
+After the licence, Setup asks what you would like to do: Install DVX,
+Upgrade or repair DVX, or Remove DVX. To install, choose the folder
+(C:\DVX unless you change it; type it, or click Browse... to pick it)
+and which fonts to install. Setup checks the free space and unpacks
+DVX. With floppies it asks for each disk it needs in turn. Setup can
+also add a line to AUTOEXEC.BAT so DVX starts with the computer; it
+shows the line first and keeps a copy of the old file as AUTOEXEC.DVX.
The fonts: None keeps DVX to its own font; the base set adds DejaVu
Sans, Sans Mono, and Serif in all their styles (about 2 MB); All adds
@@ -53,19 +54,20 @@ Setup never writes to the CD or the floppy disks.
Upgrading or repairing
----------------------
-Run Setup again and choose the same folder. Setup asks whether to keep
-your settings and saved files (the USER folder); say Yes to upgrade or
-repair DVX and keep them. An upgrade never changes the USER folder. Setup starts with the fonts the folder already has; choose
-others to add fonts or remove them.
+Run Setup again, choose Upgrade or repair DVX, and choose the folder
+DVX is in. Your settings and saved files (the USER folder) are kept;
+tick Start over to remove them instead. Setup starts with the fonts the
+folder already has; choose others to add fonts or remove them.
Removing DVX
------------
-Run SETUP.EXE in the DVX folder (for example C:\DVX\SETUP) and choose
-Uninstall. Setup removes the files it installed and, if you ask, your
-settings; files you added yourself stay. It can also take the DVX line
-out of AUTOEXEC.BAT.
+Run Setup, choose Remove DVX, and choose the folder DVX is in (or run
+SETUP.EXE in the DVX folder, for example C:\DVX\SETUP, which removes
+DVX from that folder). Setup removes the files it installed and, if you
+ask, your settings; files you added yourself stay. It can also take the
+DVX line out of AUTOEXEC.BAT.
Starting DVX
@@ -81,10 +83,11 @@ Unattended install
SETUP /AUTO=C:\DVX [/FONTS=NONE|BASE|ALL] [/LOG=C:\SETUP.LOG]
-installs without asking (the licence is taken as accepted, the USER
-folder is kept on an upgrade, and AUTOEXEC.BAT is not changed). /FONTS chooses the
-fonts; without it Setup installs the fonts the folder already has, or
-all of them in a new folder. The exit code is 0 when DVX was installed.
+installs without asking (the licence is taken as accepted, a folder
+that has DVX is upgraded with its USER folder kept, and AUTOEXEC.BAT is
+not changed). /FONTS chooses the fonts; without it Setup installs the
+fonts the folder already has, or all of them in a new folder. The exit
+code is 0 when DVX was installed.
DVX is free software under the MIT licence (see the licence in Setup).
diff --git a/src/installer/mkDist.sh b/src/installer/mkDist.sh
index ab147a87..59bb0a4c 100755
--- a/src/installer/mkDist.sh
+++ b/src/installer/mkDist.sh
@@ -37,6 +37,10 @@
# and DVX.P01; Disk n = DVX.P0n
# dist/sdk.zip the SDK (bin/sdk) in DVX-SDK/, with the
# technical and BASIC references in docs/
+# dist/DVXUPD.DPK DVX's own update (kpunch.dvx): the package's
+# files but the fonts (Setup's optional tiers),
+# for the package server, where the operator
+# signs it (server/README.md)
#
# FLOPPY: 360K, 720K, 1.2M, 1.44M (default), 2.88M -- images of that
# format -- or a number of bytes: the room on each disk, and folders
@@ -168,6 +172,16 @@ for ((n = 1; n <= DISKS; n++)); do
fi
done
+# DVX's own update: the staged release but its fonts, from the template
+# the package tool writes (this DVX's version and ABI)
+UPD_STAGE="${DIST}/updstage"
+UPD_TEMPLATE="${DIST}/dvxupd.ini"
+mkdir -p "${UPD_STAGE}"
+tar -C "${STAGE}" --exclude="./${FONTS_DIR}" -cf - . | tar -C "${UPD_STAGE}" -xf -
+"${TOOL}" dvx "${UPD_TEMPLATE}" || fail "the package tool did not write DVX's package template"
+"${TOOL}" build "${UPD_TEMPLATE}" "${UPD_STAGE}" "${DIST}/DVXUPD.DPK" > /dev/null || fail "DVX's update package could not be built"
+rm -rf "${UPD_STAGE}" "${UPD_TEMPLATE}"
+
rm -rf "${STAGE}"
# The SDK: bin/sdk and the references (make all writes docs/) in DVX-SDK/
@@ -182,3 +196,4 @@ echo "mkDist: SETUP.EXE ${SETUP_SIZE} bytes; package $(stat -c %s "${DIST}/dvx.z
echo "mkDist: CD: ${DIST}/DVX.ISO (SETUP.EXE $(stat -c %s "${DIST}/cd/SETUP.EXE") bytes with the package)"
echo "mkDist: floppies (${FLOPPY}): ${DISKS} disk(s) in ${DIST}/floppy, ${ROOM1} bytes of the package on Disk 1"
echo "mkDist: SDK: ${DIST}/sdk.zip ($(stat -c %s "${DIST}/sdk.zip") bytes)"
+echo "mkDist: DVX's update: ${DIST}/DVXUPD.DPK ($(stat -c %s "${DIST}/DVXUPD.DPK") bytes)"
diff --git a/src/installer/pkgRead.c b/src/installer/pkgRead.c
index 5933465f..84f52aa5 100644
--- a/src/installer/pkgRead.c
+++ b/src/installer/pkgRead.c
@@ -21,10 +21,15 @@
// IN THE SOFTWARE.
// pkgRead.c -- finding the DVX package and unpacking it (see pkgRead.h)
+//
+// The ZIP itself is read by the package library's stream reader
+// (pkgZip.c); this file is Setup's side of it: the bytes come from the
+// parts (the CD's one, or a floppy's, asking for the next disk when one
+// runs out), and each file goes to its place in the DVX folder.
#include "pkgRead.h"
+#include "pkgZip.h"
#include "fileops/fileOps.h"
-#include "miniz.h"
#include "stb_ds_wrap.h"
#include
@@ -32,38 +37,11 @@
#include
#include
#include
-#include
#include "dvxMem.h"
-// Bytes read from a part at a time, and the work one step does
-#define PKG_IN_BUF 16384
+// The work one step does
#define PKG_STEP_BYTES 32768
-// Local header field offsets (after the signature)
-#define LH_FLAGS 6
-#define LH_METHOD 8
-#define LH_TIME 10
-#define LH_DATE 12
-#define LH_CRC 14
-#define LH_PACKED 18
-#define LH_SIZE 22
-#define LH_NAME_LEN 26
-#define LH_EXTRA_LEN 28
-#define ZIP_SIG_SIZE 4
-
-// DOS date and time fields
-#define DOS_YEAR_SHIFT 9
-#define DOS_YEAR_MASK 0x7F
-#define DOS_YEAR_BASE 1980
-#define DOS_MONTH_SHIFT 5
-#define DOS_MONTH_MASK 0x0F
-#define DOS_DAY_MASK 0x1F
-#define DOS_HOUR_SHIFT 11
-#define DOS_MINUTE_SHIFT 5
-#define DOS_MINUTE_MASK 0x3F
-#define DOS_SECOND_MASK 0x1F
-#define TM_YEAR_BASE 1900
-
#define PERCENT_FULL 100
typedef enum {
@@ -82,29 +60,16 @@ struct PkgJobT {
int32_t disk;
uint32_t partLeft; // bytes of the part not read yet
uint32_t zipPos; // ZIP bytes read from all parts
- uint8_t in[PKG_IN_BUF];
- uint32_t inPos;
- uint32_t inLen;
+ PkgZipT *zip;
JobStateE state;
bool cancel;
bool failed;
char msg[PKG_MSG_MAX];
// the file being unpacked
- char name[PKG_NAME_MAX];
+ PkgZipEntryT entry;
char shownName[PKG_NAME_MAX]; // name in native separators, for people
char outPath[DVX_MAX_PATH];
FILE *out;
- uint16_t method;
- uint16_t dosDate;
- uint16_t dosTime;
- uint32_t crcWant;
- uint32_t crcHave;
- uint32_t packedLeft;
- uint32_t size;
- uint32_t sizeDone;
- tinfl_decompressor inflator;
- uint8_t dict[TINFL_LZ_DICT_SIZE];
- uint32_t dictPos;
// totals
uint64_t bytesDone; // files finished
int32_t filesDone;
@@ -117,7 +82,6 @@ struct PkgJobT {
// Prototypes
// ============================================================
-static time_t dosTimeToTime(uint16_t date, uint16_t time);
static bool ensureDirs(PkgJobT *job, const char *relName, bool lastIsDir);
static void entryAbort(PkgJobT *job);
static PkgStepE entryData(PkgJobT *job);
@@ -125,10 +89,11 @@ static bool entryFinish(PkgJobT *job);
static PkgStepE entryHeader(PkgJobT *job);
static bool entryOpen(PkgJobT *job);
static PkgStepE fail(PkgJobT *job, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
-static bool fill(PkgJobT *job);
+static bool fileWrite(void *ctx, const uint8_t *data, int32_t len);
static bool openPart(PkgJobT *job, int32_t disk);
static PkgDiskProblemE otherDisk(const PkgJobT *job, int32_t wanted);
static bool partPath(const PkgSourceT *src, int32_t disk, char *out, int32_t outSize);
+static int32_t partRead(void *ctx, uint8_t *buf, int32_t len);
void pkgFind(const char *exePath, PkgSourceT *src);
void pkgJobCancel(PkgJobT *job);
PkgJobT *pkgJobCreate(const PkgSourceT *src, const char *destDir, int32_t tier, PkgAskDiskFnT ask, void *user);
@@ -140,24 +105,7 @@ void pkgJobProgress(const PkgJobT *job, PkgProgressT *out);
PkgStepE pkgJobStep(PkgJobT *job);
char *const *pkgJobWritten(const PkgJobT *job);
int32_t pkgPartInDir(const char *dir);
-static bool readBytes(PkgJobT *job, uint8_t *buf, uint32_t len);
-static bool writeOut(PkgJobT *job, const uint8_t *data, uint32_t len);
-static uint32_t zipConsumed(const PkgJobT *job);
-
-
-static time_t dosTimeToTime(uint16_t date, uint16_t time) {
- struct tm t;
-
- memset(&t, 0, sizeof(t));
- t.tm_year = ((date >> DOS_YEAR_SHIFT) & DOS_YEAR_MASK) + DOS_YEAR_BASE - TM_YEAR_BASE;
- t.tm_mon = ((date >> DOS_MONTH_SHIFT) & DOS_MONTH_MASK) - 1;
- t.tm_mday = date & DOS_DAY_MASK;
- t.tm_hour = time >> DOS_HOUR_SHIFT;
- t.tm_min = (time >> DOS_MINUTE_SHIFT) & DOS_MINUTE_MASK;
- t.tm_sec = (time & DOS_SECOND_MASK) * 2;
- t.tm_isdst = -1;
- return mktime(&t);
-}
+static PkgStepE zipFailure(PkgJobT *job);
// Make every folder of relName that does not exist yet (the last part
@@ -213,74 +161,25 @@ static void entryAbort(PkgJobT *job) {
}
-// Unpack (or skip) up to PKG_STEP_BYTES of the current file.
+// Unpack up to PKG_STEP_BYTES of the current file.
static PkgStepE entryData(PkgJobT *job) {
- uint32_t work = 0;
+ PkgZipStepE step = pkgZipData(job->zip, fileWrite, job, PKG_STEP_BYTES);
- while (work < PKG_STEP_BYTES) {
- if (job->packedLeft > 0 && !fill(job)) {
- return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
- }
-
- uint32_t avail = job->inLen - job->inPos;
-
- if (avail > job->packedLeft) {
- avail = job->packedLeft;
- }
-
- bool done;
-
- if (job->method == PKG_ZIP_STORED) {
- if (!writeOut(job, job->in + job->inPos, avail)) {
- return PkgStepErrorE;
- }
-
- job->inPos += avail;
- job->packedLeft -= avail;
- work += avail;
- done = job->packedLeft == 0;
- } else {
- size_t inBytes = avail;
- size_t outBytes = TINFL_LZ_DICT_SIZE - job->dictPos;
- int flags = job->packedLeft > avail ? TINFL_FLAG_HAS_MORE_INPUT : 0;
-
- tinfl_status status = tinfl_decompress(&job->inflator, job->in + job->inPos, &inBytes, job->dict, job->dict + job->dictPos, &outBytes, (mz_uint32)flags);
-
- job->inPos += (uint32_t)inBytes;
- job->packedLeft -= (uint32_t)inBytes;
- work += (uint32_t)(inBytes + outBytes);
-
- if (outBytes > 0 && !writeOut(job, job->dict + job->dictPos, (uint32_t)outBytes)) {
- return PkgStepErrorE;
- }
-
- job->dictPos = (job->dictPos + (uint32_t)outBytes) & (TINFL_LZ_DICT_SIZE - 1);
-
- if (status < TINFL_STATUS_DONE || (status == TINFL_STATUS_NEEDS_MORE_INPUT && job->packedLeft == 0)) {
- entryAbort(job);
- return fail(job, "The file %s in the DVX package is damaged (disk %d).", job->shownName, (int)job->disk);
- }
-
- done = status == TINFL_STATUS_DONE;
- }
-
- if (done) {
- return entryFinish(job) ? PkgStepMoreE : PkgStepErrorE;
- }
+ if (step == PkgZipMoreE) {
+ return PkgStepMoreE;
}
- return PkgStepMoreE;
+ if (step == PkgZipFailE) {
+ entryAbort(job);
+ return zipFailure(job);
+ }
+
+ return entryFinish(job) ? PkgStepMoreE : PkgStepErrorE;
}
-// The file's data has all been read: check it, close it, date it.
+// The file's data has all been read and checked: close it, date it.
static bool entryFinish(PkgJobT *job) {
- if (job->packedLeft != 0 || job->sizeDone != job->size || job->crcHave != job->crcWant) {
- entryAbort(job);
- fail(job, "The file %s in the DVX package is damaged (disk %d).", job->shownName, (int)job->disk);
- return false;
- }
-
FILE *f = job->out;
job->out = NULL;
@@ -291,9 +190,9 @@ static bool entryFinish(PkgJobT *job) {
return false;
}
- fileOpsSetTime(job->outPath, dosTimeToTime(job->dosDate, job->dosTime));
+ fileOpsSetTime(job->outPath, pkgZipDosTime(job->entry.dosDate, job->entry.dosTime));
- job->bytesDone += job->size;
+ job->bytesDone += job->entry.size;
job->filesDone++;
job->state = JobHeaderE;
return true;
@@ -303,16 +202,13 @@ static bool entryFinish(PkgJobT *job) {
// The next local header, or the end: the end of the tiers to unpack (read
// no further, so no later disk is asked for) or the central directory.
static PkgStepE entryHeader(PkgJobT *job) {
- uint8_t hdr[PKG_ZIP_LOCAL_SIZE];
- bool end = zipConsumed(job) >= job->want.end;
+ PkgZipStepE step = pkgZipOffset(job->zip) >= job->want.end ? PkgZipEndE : pkgZipNext(job->zip, &job->entry);
- if (!end && !readBytes(job, hdr, ZIP_SIG_SIZE)) {
- return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
+ if (step == PkgZipFailE) {
+ return zipFailure(job);
}
- uint32_t sig = end ? PKG_ZIP_CENTRAL_SIG : pkgGet32(hdr);
-
- if (sig == PKG_ZIP_CENTRAL_SIG) {
+ if (step == PkgZipEndE) {
if (job->filesDone != (int32_t)job->want.fileCount) {
return fail(job, "The DVX package is incomplete: %d of %d files.", (int)job->filesDone, (int)job->want.fileCount);
}
@@ -321,68 +217,23 @@ static PkgStepE entryHeader(PkgJobT *job) {
return PkgStepDoneE;
}
- if (sig != PKG_ZIP_LOCAL_SIG) {
- return fail(job, "The DVX package is damaged (disk %d).", (int)job->disk);
- }
-
- if (!readBytes(job, hdr + ZIP_SIG_SIZE, PKG_ZIP_LOCAL_SIZE - ZIP_SIG_SIZE)) {
- return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
- }
-
- uint16_t flags = pkgGet16(hdr + LH_FLAGS);
- uint16_t nameLen = pkgGet16(hdr + LH_NAME_LEN);
- uint16_t extraLen = pkgGet16(hdr + LH_EXTRA_LEN);
-
- job->method = pkgGet16(hdr + LH_METHOD);
- job->dosTime = pkgGet16(hdr + LH_TIME);
- job->dosDate = pkgGet16(hdr + LH_DATE);
- job->crcWant = pkgGet32(hdr + LH_CRC);
- job->packedLeft = pkgGet32(hdr + LH_PACKED);
- job->size = pkgGet32(hdr + LH_SIZE);
-
- if ((flags & (PKG_ZIP_FLAG_DESC | PKG_ZIP_FLAG_CRYPT)) || (job->method != PKG_ZIP_STORED && job->method != PKG_ZIP_DEFLATED) || nameLen == 0 || nameLen >= PKG_NAME_MAX) {
- return fail(job, "The DVX package uses a form Setup cannot read (disk %d).", (int)job->disk);
- }
-
- if (!readBytes(job, (uint8_t *)job->name, nameLen)) {
- return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
- }
-
- job->name[nameLen] = '\0';
-
- for (uint16_t left = extraLen; left > 0; ) {
- uint8_t skip[PKG_ZIP_LOCAL_SIZE];
- uint16_t n = left < sizeof(skip) ? left : (uint16_t)sizeof(skip);
-
- if (!readBytes(job, skip, n)) {
- return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
- }
-
- left -= n;
- }
-
- bool isDir = job->name[nameLen - 1] == PKG_NAME_SEP;
-
- if (isDir) {
- job->name[nameLen - 1] = '\0';
- }
-
- memcpy(job->shownName, job->name, sizeof(job->shownName));
+ memcpy(job->shownName, job->entry.name, sizeof(job->shownName));
platformPathNative(job->shownName);
- if (!pkgNameIsValid(job->name)) {
+ if (!pkgNameIsValid(job->entry.name)) {
return fail(job, "The DVX package holds a name Setup does not accept: %s", job->shownName);
}
- if (isDir) {
- if (!ensureDirs(job, job->name, true)) {
+ if (job->entry.isDir) {
+ if (!ensureDirs(job, job->entry.name, true)) {
return PkgStepErrorE;
}
- char *listed = (char *)malloc(nameLen + 1);
+ size_t len = strlen(job->entry.name) + sizeof("/"); // the name, '/', NUL
+ char *listed = (char *)malloc(len);
if (listed) {
- snprintf(listed, nameLen + 1, "%s%c", job->name, PKG_NAME_SEP);
+ snprintf(listed, len, "%s%c", job->entry.name, PKG_NAME_SEP);
arrput(job->files, listed);
}
@@ -395,22 +246,18 @@ static PkgStepE entryHeader(PkgJobT *job) {
// Set up the file of the header just read: opened for writing.
static bool entryOpen(PkgJobT *job) {
- if (!ensureDirs(job, job->name, false)) {
+ if (!ensureDirs(job, job->entry.name, false)) {
return false;
}
- if (!pkgLocalPath(job->destDir, job->name, job->outPath, sizeof(job->outPath))) {
+ if (!pkgLocalPath(job->destDir, job->entry.name, job->outPath, sizeof(job->outPath))) {
fail(job, "The path of %s is too long.", job->shownName);
return false;
}
- arrput(job->files, strdup(job->name));
- job->sizeDone = 0;
- job->crcHave = (uint32_t)MZ_CRC32_INIT;
- job->dictPos = 0;
- job->state = JobDataE;
- tinfl_init(&job->inflator);
- job->out = fopen(job->outPath, "wb");
+ arrput(job->files, strdup(job->entry.name));
+ job->state = JobDataE;
+ job->out = fopen(job->outPath, "wb");
// A read-only copy (an older install copied from a CD) is replaced.
if (!job->out && fileOpsSetAttr(job->outPath, 0)) {
@@ -438,35 +285,15 @@ static PkgStepE fail(PkgJobT *job, const char *fmt, ...) {
}
-// Make sure the input buffer has a byte, opening the next part when this
-// one is used up. False on an error (message set) or a cancel.
-static bool fill(PkgJobT *job) {
- if (job->inPos < job->inLen) {
- return true;
- }
+// The reader's unpacked bytes, into the open file.
+static bool fileWrite(void *ctx, const uint8_t *data, int32_t len) {
+ PkgJobT *job = (PkgJobT *)ctx;
- while (job->partLeft == 0) {
- if (job->disk >= job->src.info.diskCount) {
- fail(job, "The DVX package ends too early (disk %d).", (int)job->disk);
- return false;
- }
-
- if (!openPart(job, job->disk + 1)) {
- return false;
- }
- }
-
- uint32_t n = job->partLeft < PKG_IN_BUF ? job->partLeft : PKG_IN_BUF;
-
- if (fread(job->in, 1, n, job->part) != n) {
- fail(job, "Setup could not read the DVX package on disk %d.", (int)job->disk);
+ if (fwrite(data, 1, (size_t)len, job->out) != (size_t)len) {
+ fail(job, "Setup could not write %s. The disk may be full.", job->outPath);
return false;
}
- job->inPos = 0;
- job->inLen = n;
- job->partLeft -= n;
- job->zipPos += n;
return true;
}
@@ -573,6 +400,36 @@ static bool partPath(const PkgSourceT *src, int32_t disk, char *out, int32_t out
}
+// The ZIP reader's bytes: the rest of this part, opening the next part
+// (asking for its disk) when this one is used up. -1 on an error
+// (message set) or a cancel.
+static int32_t partRead(void *ctx, uint8_t *buf, int32_t len) {
+ PkgJobT *job = (PkgJobT *)ctx;
+
+ while (job->partLeft == 0) {
+ if (job->disk >= job->src.info.diskCount) {
+ fail(job, "The DVX package ends too early (disk %d).", (int)job->disk);
+ return -1;
+ }
+
+ if (!openPart(job, job->disk + 1)) {
+ return -1;
+ }
+ }
+
+ uint32_t n = job->partLeft < (uint32_t)len ? job->partLeft : (uint32_t)len;
+
+ if (fread(buf, 1, n, job->part) != n) {
+ fail(job, "Setup could not read the DVX package on disk %d.", (int)job->disk);
+ return -1;
+ }
+
+ job->partLeft -= n;
+ job->zipPos += n;
+ return (int32_t)n;
+}
+
+
void pkgFind(const char *exePath, PkgSourceT *src) {
PkgHeaderT h;
uint32_t start = 0;
@@ -625,6 +482,13 @@ PkgJobT *pkgJobCreate(const PkgSourceT *src, const char *destDir, int32_t tier,
return NULL;
}
+ job->zip = pkgZipOpen(partRead, job);
+
+ if (!job->zip) {
+ free(job);
+ return NULL;
+ }
+
job->src = *src;
job->want = src->info.tiers[DVX_MAX(0, DVX_MIN(tier, PKG_TIERS - 1))];
job->ask = ask;
@@ -678,6 +542,7 @@ void pkgJobFree(PkgJobT *job) {
arrfree(job->files);
arrfree(job->written);
arrfree(job->createdDirs);
+ pkgZipClose(job->zip);
free(job);
}
@@ -688,7 +553,7 @@ const char *pkgJobMessage(const PkgJobT *job) {
void pkgJobProgress(const PkgJobT *job, PkgProgressT *out) {
- uint64_t done = job->bytesDone + (job->state == JobDataE ? job->sizeDone : 0);
+ uint64_t done = job->bytesDone + (job->state == JobDataE ? pkgZipDoneBytes(job->zip) : 0);
memset(out, 0, sizeof(*out));
out->bytesTotal = job->want.bytes;
@@ -742,50 +607,22 @@ int32_t pkgPartInDir(const char *dir) {
}
-// Exactly len bytes of the stream, across parts.
-static bool readBytes(PkgJobT *job, uint8_t *buf, uint32_t len) {
- while (len > 0) {
- if (!fill(job)) {
- return false;
- }
+// What the ZIP reader stopped on, as Setup says it. A read or write the
+// job's own callbacks refused already has its message (or is a cancel).
+static PkgStepE zipFailure(PkgJobT *job) {
+ switch (pkgZipProblem(job->zip)) {
+ case PkgZipReadFailedE:
+ case PkgZipWriteFailedE:
+ return job->cancel ? PkgStepCancelledE : PkgStepErrorE;
- uint32_t n = job->inLen - job->inPos;
+ case PkgZipUnreadableE:
+ return fail(job, "The DVX package uses a form Setup cannot read (disk %d).", (int)job->disk);
- if (n > len) {
- n = len;
- }
+ default:
+ if (job->state == JobDataE) {
+ return fail(job, "The file %s in the DVX package is damaged (disk %d).", job->shownName, (int)job->disk);
+ }
- memcpy(buf, job->in + job->inPos, n);
- job->inPos += n;
- buf += n;
- len -= n;
+ return fail(job, "The DVX package is damaged (disk %d).", (int)job->disk);
}
-
- return true;
-}
-
-
-static bool writeOut(PkgJobT *job, const uint8_t *data, uint32_t len) {
- if (job->sizeDone + len > job->size) {
- entryAbort(job);
- fail(job, "The file %s in the DVX package is damaged (disk %d).", job->shownName, (int)job->disk);
- return false;
- }
-
- if (fwrite(data, 1, len, job->out) != len) {
- entryAbort(job);
- fail(job, "Setup could not write %s. The disk may be full.", job->outPath);
- return false;
- }
-
- job->crcHave = (uint32_t)mz_crc32(job->crcHave, data, len);
- job->sizeDone += len;
- return true;
-}
-
-
-// The ZIP offset of the next byte to be taken (read from the parts, less
-// what is still buffered)
-static uint32_t zipConsumed(const PkgJobT *job) {
- return job->zipPos - (job->inLen - job->inPos);
}
diff --git a/src/installer/pkgTool.c b/src/installer/pkgTool.c
index 9d9383a0..9df382d0 100644
--- a/src/installer/pkgTool.c
+++ b/src/installer/pkgTool.c
@@ -20,7 +20,8 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
-// pkgTool.c -- dvxpkg, the package tool of `make dist` (build machine)
+// pkgTool.c -- dvxpkg, the package tool of the build machine: Setup's
+// package (for `make dist`) and add-on packages (pkgCli.h)
//
// dvxpkg zip FOLDER OUT.ZIP [TIER.LST ...] ZIP a staged DVX folder;
// each list file names a
@@ -28,9 +29,12 @@
// rule a line (pkgBuild.h)
// dvxpkg cd SETUP.EXE PKG.ZIP OUT.EXE SETUP.EXE with the package
// dvxpkg split PKG.ZIP OUTDIR DISK1ROOM ROOM DVX.P01.. for floppies
-// dvxpkg info FILE a part's header
+// dvxpkg info FILE a part's header, or an
+// add-on package's manifest
+// dvxpkg build | sign | verify | keygen ... add-on packages (pkgCli.h)
#include "pkgBuild.h"
+#include "pkgCli.h"
#include
#include
@@ -43,6 +47,7 @@
#define ARGS_SPLIT 6
#define ARGS_INFO 3
#define EXIT_USAGE 2
+#define RANDOM_DEV "/dev/urandom"
// ============================================================
// Prototypes
@@ -50,6 +55,8 @@
static int cmdInfo(const char *path);
static void freeRules(PkgTierRuleT *rules, int32_t count);
+static bool hasPartHeader(const char *path);
+static bool hostRandom(uint8_t *buf, int32_t len);
static bool parseBytes(const char *text, uint32_t *out);
static void printTiers(const PkgHeaderT *h);
static bool readRules(const char *path, PkgTierRuleT *rule);
@@ -90,6 +97,33 @@ static void freeRules(PkgTierRuleT *rules, int32_t count) {
}
+static bool hasPartHeader(const char *path) {
+ PkgHeaderT h;
+ uint32_t start = 0;
+ FILE *f = fopen(path, "rb");
+ bool found = f && pkgHeaderRead(f, &h, &start);
+
+ if (f) {
+ fclose(f);
+ }
+
+ return found;
+}
+
+
+// A key's seed from the system's random source.
+static bool hostRandom(uint8_t *buf, int32_t len) {
+ FILE *f = fopen(RANDOM_DEV, "rb");
+ bool ok = f && fread(buf, 1, (size_t)len, f) == (size_t)len;
+
+ if (f) {
+ fclose(f);
+ }
+
+ return ok;
+}
+
+
static bool parseBytes(const char *text, uint32_t *out) {
char *end = NULL;
uint64_t v = strtoull(text, &end, 10);
@@ -152,6 +186,7 @@ static bool readRules(const char *path, PkgTierRuleT *rule) {
static int usage(void) {
fprintf(stderr, "usage: dvxpkg zip FOLDER OUT.ZIP [TIER.LST ...]\n dvxpkg cd SETUP.EXE PKG.ZIP OUT.EXE\n dvxpkg split PKG.ZIP OUTDIR DISK1ROOM ROOM\n dvxpkg info FILE\n");
+ pkgCliUsage(stderr);
return EXIT_USAGE;
}
@@ -215,9 +250,12 @@ int main(int argc, char *argv[]) {
return EXIT_SUCCESS;
}
- if (argc == ARGS_INFO && strcmp(argv[1], "info") == 0) {
+ if (argc == ARGS_INFO && strcmp(argv[1], "info") == 0 && hasPartHeader(argv[2])) {
return cmdInfo(argv[2]);
}
- return usage();
+ bool handled = false;
+ int result = pkgCliRun(argc, argv, hostRandom, &handled);
+
+ return handled ? result : usage();
}
diff --git a/src/installer/setup.c b/src/installer/setup.c
index a0ff3c2a..e462af0b 100644
--- a/src/installer/setup.c
+++ b/src/installer/setup.c
@@ -22,16 +22,19 @@
// setup.c -- SETUP.EXE: installs DVX from its CD or floppies
//
-// One self-contained DOS program: the DVX core and the eight widgets it
-// uses are linked in (no DVX folder needed), and it never writes to the
-// disk it runs from. The package is appended to SETUP.EXE on a CD, or in
+// One self-contained DOS program: the DVX core and the widgets it uses
+// are linked in (no DVX folder needed), and it never writes to the disk
+// it runs from. The package is appended to SETUP.EXE on a CD, or in
// DVX.P01.. beside it on floppies (pkgRead.h). Pages in one window:
-// licence, folder (with the choice of fonts, and Uninstall), progress,
-// done. The fonts are the package's later tiers: none, the base set, or
-// all of them are unpacked (the disks after the last one chosen are never
-// asked for), and an upgrade to fewer fonts removes the others. Without
-// a package (the SETUP.EXE installed in the DVX folder) it offers only
-// Uninstall of its own folder.
+// licence; start, a tile (icon, button, and what it does) for each of
+// Install, Upgrade or repair, and Remove; the folder page, set up for the
+// one chosen (the folder, with Browse...; the fonts and AUTOEXEC.BAT to
+// install or upgrade; the user folder to start over or remove);
+// progress; done. The fonts are the package's later tiers: none, the
+// base set, or all of them are unpacked (the disks after the last one
+// chosen are never asked for), and an upgrade to fewer fonts removes the
+// others. Without a package (the SETUP.EXE installed in the DVX folder)
+// it offers only Remove of its own folder.
//
// The window sits on a splash backdrop drawn into the desktop's wallpaper
// buffer: a blue gradient with a large shadowed DVX title (the system
@@ -48,17 +51,21 @@
// upgrade, AUTOEXEC.BAT untouched); exit
// code 0 when installed
// SETUP /AUTO=.. /FONTS=BASE NONE, BASE, or ALL fonts (else what the
-// folder has; all on a new install)
+// folder has; all on a new install); without
+// /AUTO, the choice the folder page starts
+// with
// SETUP /LOG=C:\SETUP.LOG write the log there (else it stays in memory)
// SETUP /AUTO=.. /SHOTS=C:\X test only (the DOS test harness; not in the
// user docs): also show every page an
-// install shows -- licence, folder, progress
-// (before the unpacking starts), done,
-// uninstall -- and save each as C:\X\SU-*.BMP
-// (sShotNames); also ask the questions /AUTO
-// does not ask (upgrade, disk prompts), save
-// them and answer them itself (ask);
-// ignored without /AUTO
+// install shows -- licence, start, the folder
+// page as Install or Upgrade (what /AUTO
+// does), progress (before the unpacking
+// starts), done, the folder page as Remove --
+// and save each as C:\X\SU-*.BMP (sShotNames,
+// sActions); also ask the questions /AUTO
+// does not ask (disk prompts), save them and
+// answer them itself (ask); ignored without
+// /AUTO
//
// The unpacking runs from the main loop in slices, drawing progress and
// seeing Cancel between them; the questions (disk swaps, errors) are
@@ -74,6 +81,7 @@
#include "box/box.h"
#include "button/button.h"
#include "checkbox/checkbox.h"
+#include "imageButton/imgBtn.h"
#include "label/label.h"
#include "progressBar/progress.h"
#include "radio/radio.h"
@@ -82,6 +90,7 @@
#include "pkgRead.h"
#include "setupCore.h"
#include "setupFonts.h"
+#include "setupIcons.h"
#include "setupLicense.h"
#include "stb_ds_wrap.h"
@@ -115,8 +124,8 @@
#define SETUP_ARG_FONTS "/FONTS="
#define SETUP_BYTES_PER_MB (1024 * 1024)
#define SETUP_EXIT_FAILED 1
-#define SETUP_SHOT_UPGRADE "SU-UPGR.BMP"
#define SETUP_DISK_POLL_MS 250
+#define SETUP_TILE_LINES 2 // under a start page tile's button
// Billboards (board*): each shows SETUP_BOARD_MS while files are unpacked
#define SETUP_BOARD_MS 4000
@@ -152,7 +161,10 @@
void wgtRegisterBox(void);
void wgtRegisterButton(void);
void wgtRegisterCheckbox(void);
+void wgtRegisterDropdown(void);
+void wgtRegisterImageButton(void);
void wgtRegisterLabel(void);
+void wgtRegisterListBox(void);
void wgtRegisterProgress(void);
void wgtRegisterRadio(void);
void wgtRegisterSpacer(void);
@@ -160,13 +172,33 @@ void wgtRegisterTextInput(void);
typedef enum {
PageLicenceE,
+ PageStartE,
PageFolderE,
PageCopyE,
PageDoneE,
- PageUninstallE,
PageCountE
} PageE;
+// What the folder page does: the start page's choice
+typedef enum {
+ ActionInstallE,
+ ActionUpgradeE,
+ ActionRemoveE,
+ ActionCountE
+} SetupActionE;
+
+// A choice of the start page: its tile, and the folder page it sets up
+typedef struct {
+ const char *title; // the tile's button
+ const char *lines[SETUP_TILE_LINES]; // under it ("": none)
+ const uint8_t *icon; // a BMP (setupIcons.h)
+ uint32_t iconLen;
+ const char *folderLabel; // the folder box's label
+ const char *button; // the folder page's button
+ const char *userLabel; // the user folder checkbox (NULL: hidden)
+ const char *shot; // /SHOTS: the folder page's picture
+} SetupActionT;
+
// A colour as red, green, blue (packColor makes the display's pixel)
typedef struct {
uint8_t r;
@@ -217,21 +249,29 @@ typedef struct {
WindowT *win;
WidgetT *pages[PageCountE];
PageE page; // the one shown
+ SetupActionE action; // what the folder page does
WidgetT *accept; // licence accepted
WidgetT *nextBtn;
- WidgetT *spaceText; // the room the choice of fonts needs
+ WidgetT *startText; // the start page's "DVX is already installed in .."
+ WidgetT *tileBtns[ActionCountE];
+ WidgetT *folderLabel;
WidgetT *dest; // target folder
+ WidgetT *browseBtn;
+ WidgetT *fontsBox; // the fonts: label, choice, and room needed
WidgetT *fontsGroup; // the fonts choice: a radio button per SetupFontsE
+ WidgetT *spaceText; // the room the choice of fonts needs
+ WidgetT *userBox; // start over / remove the user folder
+ WidgetT *removeNote; // what Remove leaves
WidgetT *autoexec; // "start with the computer" (NULL without a boot drive)
+ WidgetT *ownNote; // without a package: where to install from
+ WidgetT *backBtn; // the folder page's
+ WidgetT *goBtn; // the folder page's Install, Upgrade, or Remove
WidgetT *status;
WidgetT *bar;
WidgetT *copyBoard; // the progress page's billboards
WidgetT *doneText; // where DVX is now
WidgetT *doneRun; // how to start it
WidgetT *startNow;
- WidgetT *unText;
- WidgetT *removeSettings;
- WidgetT *unBackBtn;
PkgSourceT src;
char exePath[DVX_MAX_PATH];
char destDir[DVX_MAX_PATH];
@@ -249,8 +289,7 @@ typedef struct {
int32_t askDisk; // /SHOTS: the disk it asks for (0: not a disk prompt)
int32_t askPart; // /SHOTS: the part in the drive when it was asked
PlatformTicksT askPoll; // /SHOTS: when the drive was last looked at
- bool installRequested;
- bool uninstallRequested;
+ bool runRequested; // the folder page's button (or /AUTO)
bool cancelRequested;
bool startDvx;
int32_t exitCode;
@@ -270,8 +309,21 @@ static const char *const sFoundNames[] = { "appended", "in parts", "none", "dama
static const char *const sFontsArgs[SetupFontsCountE] = { "NONE", "BASE", "ALL" };
static const char *const sFontsLabels[SetupFontsCountE] = { "&None: DVX's own font only", "Base s&et: DejaVu Sans, Sans Mono, and Serif", "A&ll fonts" };
-// /SHOTS: each page's picture (PageE order, 8.3 names)
-static const char *const sShotNames[PageCountE] = { "SU-LIC.BMP", "SU-FOLD.BMP", "SU-COPY.BMP", "SU-DONE.BMP", "SU-UNIN.BMP" };
+// /SHOTS: each page's picture (8.3 names; the folder page's is its
+// action's)
+static const char *const sShotNames[PageCountE] = {
+ [PageLicenceE] = "SU-LIC.BMP",
+ [PageStartE] = "SU-START.BMP",
+ [PageCopyE] = "SU-COPY.BMP",
+ [PageDoneE] = "SU-DONE.BMP"
+};
+
+// The start page's choices (labels do not wrap: a tile has short lines)
+static const SetupActionT sActions[ActionCountE] = {
+ [ActionInstallE] = { "&Install DVX", { "Put DVX on this computer, in a folder you choose.", "" }, sIconInstall, (uint32_t)sizeof(sIconInstall), "Install DVX in this &folder:", "&Install", NULL, "SU-FOLD.BMP" },
+ [ActionUpgradeE] = { "&Upgrade or repair DVX", { "Bring DVX up to date, or put back missing files.", "Your settings and saved files are kept." }, sIconUpgrade, (uint32_t)sizeof(sIconUpgrade), "Upgrade or repair DVX in this &folder:", "&Upgrade", "Start &over: also remove my settings and saved files", "SU-UPGR.BMP" },
+ [ActionRemoveE] = { "&Remove DVX", { "Take DVX off this computer.", "" }, sIconRemove, (uint32_t)sizeof(sIconRemove), "Remove DVX from this &folder:", "&Remove", "Also remove my &settings and saved files (the USER folder)", "SU-UNIN.BMP" }
+};
// /SHOTS: each disk prompt's picture (PkgDiskProblemE order)
static const char *const sDiskShotNames[] = { "SU-DISK.BMP", "SU-OTHER.BMP", "SU-WRONG.BMP", "SU-BAD.BMP" };
@@ -330,13 +382,17 @@ static WidgetT *boardCreate(WidgetT *parent, const SetupBoardT *boards
static void boardDestroy(WidgetT *w);
static void boardNext(WidgetT *w);
static void boardPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors);
+static void buildFolderPage(WidgetT *p, char boot);
static void buildPages(WidgetT *root);
+static void buildStartPage(WidgetT *p);
static WidgetT *buttonRow(WidgetT *page);
static void drawShadowed(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t scale, int32_t offset, const SetupRgbT *color);
static void drawSparkle(DisplayT *d, const BlitOpsT *ops, int32_t cx, int32_t cy, int32_t radius, uint32_t color);
static void fillGradient(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, const SetupRgbT *top, const SetupRgbT *bottom);
+static void folderGet(char *out, int32_t outSize);
static SetupFontsE fontsChosen(const char *destDir);
static void fontsLoad(void);
+static SetupFontsE fontsPreset(const char *destDir);
static void installAutoexec(const char *destDir);
static bool installCheck(const char *destDir, SetupFontsE fonts, bool *upgrade, bool *keepSettings);
static PkgStepE installCopy(const char *destDir, SetupFontsE fonts, bool newInstall);
@@ -345,24 +401,26 @@ static uint64_t megabytes(uint64_t bytes);
static void onAccept(WidgetT *w);
static void onAskIdle(void *ctx);
static void onBack(WidgetT *w);
+static void onBrowse(WidgetT *w);
static void onCancel(WidgetT *w);
static void onClose(WindowT *win);
static void onFinish(WidgetT *w);
static void onFonts(WidgetT *w);
-static void onInstall(WidgetT *w);
+static void onGo(WidgetT *w);
static void onNext(WidgetT *w);
-static void onUninstall(WidgetT *w);
-static void onUninstallAsk(WidgetT *w);
+static void onTile(WidgetT *w);
static void paintBackdrop(void);
static void parseArgs(int argc, char *argv[]);
static void report(const char *msg);
static void shootFile(const char *name);
static void shootPage(void);
+static void showFolder(SetupActionE action);
static void showPage(PageE page);
static void showProgress(const PkgJobT *job);
static void showSpace(void);
-static void showUninstall(void);
+static void showStart(void);
static void startDvx(void);
+static void tileCreate(WidgetT *page, SetupActionE action);
static const BitmapFontT *typeFont(SetupTypeE type);
static void uninstallRun(void);
int main(int argc, char *argv[]);
@@ -535,10 +593,75 @@ static void boardPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitma
}
-static void buildPages(WidgetT *root) {
+// The folder page's widgets, set up per action by showFolder: the folder
+// (/AUTO=, else DVX on the boot drive) with Browse..., the fonts, the
+// user folder checkbox, what Remove leaves, AUTOEXEC.BAT (with a boot
+// drive), and the note shown only without a package.
+static void buildFolderPage(WidgetT *p, char boot) {
char text[SETUP_MSG_MAX];
- char boot = platformBootDrive();
+ sSetup.folderLabel = wgtLabel(p, "");
+
+ WidgetT *row = wgtHBox(p);
+
+ row->padding = wgtPixels(0);
+ row->spacing = wgtPixels(SETUP_SPACING);
+ sSetup.dest = wgtTextInput(row, DVX_MAX_PATH - 1);
+ sSetup.dest->weight = WGT_WEIGHT_FILL;
+ sSetup.browseBtn = wgtButton(row, "Bro&wse...");
+ sSetup.browseBtn->onClick = onBrowse;
+ text[0] = '\0';
+
+ if (sSetup.autoMode) {
+ snprintf(text, sizeof(text), "%s", sSetup.autoDest);
+ } else if (boot) {
+ snprintf(text, sizeof(text), "%c:" DVX_PATH_SEP SETUP_DEST_FOLDER, boot);
+ }
+
+ wgtSetText(sSetup.dest, text);
+
+ // The fonts, each with the room it adds to DVX's own (but for none)
+ const PkgHeaderT *info = &sSetup.src.info;
+
+ sSetup.fontsBox = wgtVBox(p);
+ sSetup.fontsBox->padding = wgtPixels(0);
+ sSetup.fontsBox->spacing = wgtPixels(SETUP_SPACING);
+ wgtLabel(sSetup.fontsBox, "Fonts to install:");
+ sSetup.fontsGroup = wgtRadioGroup(sSetup.fontsBox);
+ sSetup.fontsGroup->padding = wgtPixels(0);
+
+ for (int32_t f = 0; f < SetupFontsCountE; f++) {
+ if (f == SetupFontsNoneE) {
+ snprintf(text, sizeof(text), "%s", sFontsLabels[f]);
+ } else {
+ snprintf(text, sizeof(text), "%s (%" PRIu64 " MB)", sFontsLabels[f], megabytes(info->tiers[f].bytes - info->tiers[SetupFontsNoneE].bytes));
+ }
+
+ wgtRadio(sSetup.fontsGroup, text);
+ }
+
+ sSetup.fontsGroup->onChange = onFonts;
+ sSetup.spaceText = wgtLabel(sSetup.fontsBox, "");
+ sSetup.userBox = wgtCheckbox(p, "");
+ sSetup.removeNote = wgtLabel(p, "Files you added to the folder yourself are not removed.");
+
+ if (boot) {
+ sSetup.autoexec = wgtCheckbox(p, "&Start DVX when the computer starts (AUTOEXEC.BAT)");
+ }
+
+ sSetup.ownNote = wgtLabel(p, "To install or upgrade DVX, run SETUP from its CD or Disk 1.");
+ wgtSetVisible(sSetup.ownNote, false);
+
+ row = buttonRow(p);
+ sSetup.backBtn = wgtButton(row, "< &Back");
+ sSetup.backBtn->onClick = onBack;
+ sSetup.goBtn = wgtButton(row, "");
+ sSetup.goBtn->onClick = onGo;
+ wgtButton(row, "E&xit")->onClick = onCancel;
+}
+
+
+static void buildPages(WidgetT *root) {
for (int32_t i = 0; i < PageCountE; i++) {
sSetup.pages[i] = wgtVBox(root);
sSetup.pages[i]->weight = WGT_WEIGHT_FILL;
@@ -567,50 +690,8 @@ static void buildPages(WidgetT *root) {
wgtButton(row, "E&xit")->onClick = onCancel;
wgtSetEnabled(sSetup.nextBtn, false);
- // Folder
- p = sSetup.pages[PageFolderE];
- sSetup.spaceText = wgtLabel(p, "");
- wgtLabel(p, "Install DVX in this &folder:");
- sSetup.dest = wgtTextInput(p, DVX_MAX_PATH - 1);
- sSetup.dest->weight = 0; // one line high, not the page's spare room
- text[0] = '\0';
-
- if (boot) {
- snprintf(text, sizeof(text), "%c:" DVX_PATH_SEP SETUP_DEST_FOLDER, boot);
- sSetup.autoexec = wgtCheckbox(p, "&Start DVX when the computer starts (AUTOEXEC.BAT)");
- }
-
- wgtSetText(sSetup.dest, text);
-
- // The fonts: to start with what the folder has (all on a new install),
- // or unattended what will be installed
- const PkgHeaderT *info = &sSetup.src.info;
-
- wgtLabel(p, "Fonts to install:");
- sSetup.fontsGroup = wgtRadioGroup(p);
- sSetup.fontsGroup->padding = wgtPixels(0);
-
- for (int32_t f = 0; f < SetupFontsCountE; f++) {
- if (f == SetupFontsNoneE) {
- snprintf(text, sizeof(text), "%s", sFontsLabels[f]);
- } else {
- snprintf(text, sizeof(text), "%s (%" PRIu64 " MB)", sFontsLabels[f], megabytes(info->tiers[f].bytes - info->tiers[SetupFontsNoneE].bytes));
- }
-
- wgtRadio(sSetup.fontsGroup, text);
- }
-
- wgtRadioGroupSetSelected(sSetup.fontsGroup, (int32_t)(sSetup.autoMode ? fontsChosen(sSetup.autoDest) : setupFontsInstalled(wgtGetText(sSetup.dest), info)));
- sSetup.fontsGroup->onChange = onFonts;
- showSpace();
- // Labels do not wrap: two short statements, not one long sentence.
- wgtLabel(p, "Install also upgrades or repairs DVX in that folder.");
- wgtLabel(p, "Uninstall... removes DVX from that folder.");
- row = buttonRow(p);
- wgtButton(row, "< &Back")->onClick = onBack;
- wgtButton(row, "&Uninstall...")->onClick = onUninstallAsk;
- wgtButton(row, "&Install")->onClick = onInstall;
- wgtButton(row, "E&xit")->onClick = onCancel;
+ buildStartPage(sSetup.pages[PageStartE]);
+ buildFolderPage(sSetup.pages[PageFolderE], platformBootDrive());
// Progress
p = sSetup.pages[PageCopyE];
@@ -630,17 +711,23 @@ static void buildPages(WidgetT *root) {
wgtCheckboxSetChecked(sSetup.startNow, true);
row = buttonRow(p);
wgtButton(row, "&Finish")->onClick = onFinish;
+}
- // Uninstall
- p = sSetup.pages[PageUninstallE];
- sSetup.unText = wgtLabel(p, "");
- sSetup.removeSettings = wgtCheckbox(p, "Also remove my &settings and saved files (the USER folder)");
- wgtLabel(p, "Files you added to the folder yourself are not removed.");
- row = buttonRow(p);
- sSetup.unBackBtn = wgtButton(row, "< &Back");
- sSetup.unBackBtn->onClick = onBack;
- wgtButton(row, "&Uninstall")->onClick = onUninstall;
- wgtButton(row, "E&xit")->onClick = onCancel;
+
+// The start page: a tile per action, and (showStart) whether the folder
+// Setup suggests has DVX already.
+static void buildStartPage(WidgetT *p) {
+ wgtLabel(p, "What would you like to do?");
+ sSetup.startText = wgtLabel(p, "");
+
+ for (int32_t a = 0; a < ActionCountE; a++) {
+ tileCreate(p, (SetupActionE)a);
+ }
+
+ WidgetT *row = buttonRow(p);
+
+ wgtButton(row, "< &Back")->onClick = onBack;
+ wgtButton(row, "E&xit")->onClick = onCancel;
}
@@ -687,14 +774,18 @@ static void fillGradient(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y,
}
-// The fonts to install in destDir: /FONTS= or (unattended) what the
-// folder has, else the choice on the folder page.
-static SetupFontsE fontsChosen(const char *destDir) {
- if (sSetup.autoMode) {
- return sSetup.fontsGiven ? sSetup.fontsArg : setupFontsInstalled(destDir, &sSetup.src.info);
- }
+// The folder the folder page names (/AUTO= put there), trimmed, with the
+// native separators.
+static void folderGet(char *out, int32_t outSize) {
+ dvxCopyTrimmed(out, outSize, wgtGetText(sSetup.dest));
+ platformPathNative(out);
+}
- return (SetupFontsE)wgtRadioGroupGetSelected(sSetup.fontsGroup);
+
+// The fonts to install in destDir: unattended fontsPreset, else the
+// choice on the folder page.
+static SetupFontsE fontsChosen(const char *destDir) {
+ return sSetup.autoMode ? fontsPreset(destDir) : (SetupFontsE)wgtRadioGroupGetSelected(sSetup.fontsGroup);
}
@@ -711,6 +802,13 @@ static void fontsLoad(void) {
}
+// The fonts first chosen for destDir: /FONTS=, else what the folder has
+// (all on a new install).
+static SetupFontsE fontsPreset(const char *destDir) {
+ return sSetup.fontsGiven ? sSetup.fontsArg : setupFontsInstalled(destDir, &sSetup.src.info);
+}
+
+
// Show the exact line, ask, then back up AUTOEXEC.BAT and add it.
static void installAutoexec(const char *destDir) {
char path[DVX_MAX_PATH];
@@ -743,11 +841,13 @@ static void installAutoexec(const char *destDir) {
}
-// The folder, the free space (on an upgrade, for more fonts than it has),
-// and on a folder that already has DVX the choice between keeping and
-// replacing the settings.
+// The folder; what is in it against the action (Upgrade needs a DVX
+// there, Install over one asks to upgrade it instead); and the free space
+// (on an upgrade, for more fonts than it has). An upgrade keeps the
+// settings unless Upgrade's "start over" is ticked.
static bool installCheck(const char *destDir, SetupFontsE fonts, bool *upgrade, bool *keepSettings) {
char err[SETUP_ERR_MAX];
+ char msg[SETUP_MSG_MAX];
uint64_t needed = setupSpaceNeeded(&sSetup.src.info, fonts);
uint64_t total = 0;
uint64_t avail = 0;
@@ -758,20 +858,20 @@ static bool installCheck(const char *destDir, SetupFontsE fonts, bool *upgrade,
}
*upgrade = setupIsInstalled(destDir);
- *keepSettings = *upgrade;
+ *keepSettings = *upgrade && !(sSetup.action == ActionUpgradeE && wgtCheckboxIsChecked(sSetup.userBox));
- if (*upgrade && sSetup.asks) {
- char msg[SETUP_MSG_MAX];
+ if (!*upgrade && sSetup.action == ActionUpgradeE) {
+ snprintf(msg, sizeof(msg), "DVX is not installed in %s.\n\nTo put DVX there, choose Back, then Install DVX.", destDir);
+ report(msg);
+ return false;
+ }
- snprintf(msg, sizeof(msg), "DVX is already installed in %s.\n\nYes: upgrade or repair it and keep your settings and saved files.\nNo: replace everything, and remove your settings and saved files (the USER folder).", destDir);
+ if (*upgrade && sSetup.action == ActionInstallE && sSetup.asks) {
+ snprintf(msg, sizeof(msg), "DVX is already installed in %s.\n\nUpgrade or repair it instead, keeping your settings and saved files?", destDir);
- int32_t answer = ask(msg, MB_YESNOCANCEL | MB_ICONQUESTION, SETUP_SHOT_UPGRADE, 0);
-
- if (answer == ID_CANCEL) {
+ if (ask(msg, MB_YESNO | MB_ICONQUESTION, NULL, 0) != ID_YES) {
return false;
}
-
- *keepSettings = answer == ID_YES;
}
// An upgrade replaces files of about the same size; only more fonts
@@ -783,7 +883,6 @@ static bool installCheck(const char *destDir, SetupFontsE fonts, bool *upgrade,
}
if (needed > 0 && platformDiskSpace(destDir, &total, &avail) && avail < needed) {
- char msg[SETUP_MSG_MAX];
char needText[FILEOPS_SIZE_TEXT_MAX];
char freeText[FILEOPS_SIZE_TEXT_MAX];
@@ -847,6 +946,10 @@ static PkgStepE installCopy(const char *destDir, SetupFontsE fonts, bool newInst
if (!setupListWrite(destDir, files, (int32_t)arrlen(files))) {
dvxLog("Setup: could not write %s in %s", SETUP_LIST_NAME, destDir);
}
+
+ if (!setupRecordDvx(destDir)) {
+ dvxLog("Setup: could not record DVX as an installed package in %s", destDir);
+ }
} else {
char *const *written = pkgJobWritten(job);
char *const *dirs = pkgJobCreatedDirs(job);
@@ -870,7 +973,8 @@ static PkgStepE installCopy(const char *destDir, SetupFontsE fonts, bool newInst
}
-// Install clicked: check, unpack, settings, AUTOEXEC.BAT, done.
+// Install or Upgrade clicked (or /AUTO): check, unpack, settings,
+// AUTOEXEC.BAT, done.
static void installRun(void) {
char destDir[DVX_MAX_PATH];
char msg[SETUP_MSG_MAX];
@@ -878,8 +982,7 @@ static void installRun(void) {
bool keepSettings = false;
struct stat st;
- dvxCopyTrimmed(destDir, sizeof(destDir), sSetup.autoMode ? sSetup.autoDest : wgtGetText(sSetup.dest));
- platformPathNative(destDir);
+ folderGet(destDir, sizeof(destDir));
SetupFontsE fonts = fontsChosen(destDir);
@@ -952,10 +1055,10 @@ static void installRun(void) {
wgtSetText(sSetup.doneRun, msg);
showPage(PageDoneE);
- // /SHOTS: the done page, then the uninstall page for this folder
+ // /SHOTS: the done page, then the Remove page for this folder
if (sSetup.shots) {
shootPage();
- showUninstall();
+ showFolder(ActionRemoveE);
shootPage();
sSetup.quit = true;
}
@@ -1012,10 +1115,39 @@ static void onAskIdle(void *ctx) {
}
-// Back from the folder page to the licence, from Uninstall to the folder.
+// Back from the folder page to the start page, from there to the licence.
static void onBack(WidgetT *w) {
(void)w;
- showPage(sSetup.page == PageUninstallE ? PageFolderE : PageLicenceE);
+
+ if (sSetup.page == PageFolderE) {
+ showStart();
+ } else {
+ showPage(PageLicenceE);
+ }
+}
+
+
+// Browse...: the folder dialog, starting at the folder typed (or, not
+// made yet, the nearest one that is); on Upgrade the fonts follow the
+// folder chosen.
+static void onBrowse(WidgetT *w) {
+ char folder[DVX_MAX_PATH];
+ char chosen[DVX_MAX_PATH];
+
+ (void)w;
+ folderGet(folder, sizeof(folder));
+
+ if (!dvxFileDialog(&sSetup.ctx, NULL, FD_FOLDER, folder[0] ? folder : NULL, NULL, 0, chosen, sizeof(chosen))) {
+ return;
+ }
+
+ wgtSetText(sSetup.dest, chosen);
+ wgtSetFocused(sSetup.dest);
+
+ if (sSetup.action == ActionUpgradeE) {
+ wgtRadioGroupSetSelected(sSetup.fontsGroup, (int32_t)fontsPreset(chosen));
+ showSpace();
+ }
}
@@ -1052,41 +1184,23 @@ static void onFonts(WidgetT *w) {
}
-static void onInstall(WidgetT *w) {
+// The folder page's Install, Upgrade, or Remove: run from the main loop.
+static void onGo(WidgetT *w) {
(void)w;
- sSetup.installRequested = true;
+ sSetup.runRequested = true;
}
static void onNext(WidgetT *w) {
(void)w;
- showPage(PageFolderE);
- wgtSetFocused(sSetup.dest);
+ showStart();
}
-static void onUninstall(WidgetT *w) {
- (void)w;
- sSetup.uninstallRequested = true;
-}
-
-
-// Uninstall... on the folder page: only for a folder with DVX in it.
-static void onUninstallAsk(WidgetT *w) {
- char list[DVX_MAX_PATH];
-
- (void)w;
- dvxCopyTrimmed(sSetup.destDir, sizeof(sSetup.destDir), wgtGetText(sSetup.dest));
-
- if (!fileOpsPathJoin(list, sizeof(list), sSetup.destDir, SETUP_LIST_NAME) || access(list, F_OK) != 0) {
- char msg[SETUP_MSG_MAX];
-
- snprintf(msg, sizeof(msg), "DVX is not installed in %s (or it was not installed by this Setup).", sSetup.destDir);
- dvxErrorBox(&sSetup.ctx, SETUP_TITLE, msg);
- return;
- }
-
- showUninstall();
+// A start page tile's icon or button: the folder page for its action.
+// cppcheck-suppress constParameterCallback ; a widget onClick handler: WidgetT fixes the signature
+static void onTile(WidgetT *w) {
+ showFolder((SetupActionE)(intptr_t)w->userData);
}
@@ -1173,7 +1287,41 @@ static void shootFile(const char *name) {
// /SHOTS: draw the page shown and save it under its name.
static void shootPage(void) {
dvxUpdate(&sSetup.ctx);
- shootFile(sShotNames[sSetup.page]);
+ shootFile(sSetup.page == PageFolderE ? sActions[sSetup.action].shot : sShotNames[sSetup.page]);
+}
+
+
+// The folder page set up for action: its label and button, the fonts
+// (first what fontsPreset gives the folder) and AUTOEXEC.BAT but for
+// Remove, the user folder checkbox (cleared) for Upgrade and Remove, and
+// for Remove what it leaves.
+static void showFolder(SetupActionE action) {
+ const SetupActionT *a = &sActions[action];
+ bool remove = action == ActionRemoveE;
+
+ sSetup.action = action;
+ wgtSetText(sSetup.folderLabel, a->folderLabel);
+ wgtSetText(sSetup.goBtn, a->button);
+ wgtSetVisible(sSetup.fontsBox, !remove);
+ wgtSetVisible(sSetup.userBox, a->userLabel != NULL);
+ wgtSetText(sSetup.userBox, a->userLabel ? a->userLabel : "");
+ wgtCheckboxSetChecked(sSetup.userBox, false);
+ wgtSetVisible(sSetup.removeNote, remove);
+
+ if (sSetup.autoexec) {
+ wgtSetVisible(sSetup.autoexec, !remove);
+ }
+
+ if (!remove) {
+ char folder[DVX_MAX_PATH];
+
+ folderGet(folder, sizeof(folder));
+ wgtRadioGroupSetSelected(sSetup.fontsGroup, (int32_t)fontsPreset(folder));
+ showSpace();
+ }
+
+ showPage(PageFolderE);
+ wgtSetFocused(sSetup.dest);
}
@@ -1205,7 +1353,6 @@ static void showProgress(const PkgJobT *job) {
}
-// The uninstall page for sSetup.destDir.
// The room the fonts chosen need, on the folder page
static void showSpace(void) {
char text[SETUP_MSG_MAX];
@@ -1215,12 +1362,21 @@ static void showSpace(void) {
}
-static void showUninstall(void) {
- char msg[SETUP_MSG_MAX];
+// The start page, saying so when the folder Setup suggests has DVX
+// already; the focus on Upgrade then, else on Install.
+static void showStart(void) {
+ char folder[DVX_MAX_PATH];
+ char text[SETUP_MSG_MAX];
- snprintf(msg, sizeof(msg), "Setup will remove DVX from %s.", sSetup.destDir);
- wgtSetText(sSetup.unText, msg);
- showPage(PageUninstallE);
+ folderGet(folder, sizeof(folder));
+
+ bool installed = folder[0] && setupIsInstalled(folder);
+
+ snprintf(text, sizeof(text), "DVX is already installed in %s.", folder);
+ wgtSetText(sSetup.startText, text);
+ wgtSetVisible(sSetup.startText, installed);
+ showPage(PageStartE);
+ wgtSetFocused(sSetup.tileBtns[installed ? ActionUpgradeE : ActionInstallE]);
}
@@ -1239,21 +1395,64 @@ static void startDvx(void) {
}
+// A start page tile: the action's icon (none if it does not decode) and
+// button, which both choose it, and the lines saying what it does.
+static void tileCreate(WidgetT *page, SetupActionE action) {
+ const SetupActionT *a = &sActions[action];
+ WidgetT *tile = wgtHBox(page);
+ int32_t iconW = 0;
+ int32_t iconH = 0;
+ int32_t pitch = 0;
+ uint8_t *icon = dvxLoadImageFromMemory(&sSetup.ctx, a->icon, (int32_t)a->iconLen, &iconW, &iconH, &pitch);
+
+ tile->padding = wgtPixels(0);
+ tile->spacing = wgtPixels(SETUP_SPACING);
+
+ if (icon) {
+ WidgetT *img = wgtImageButton(tile, icon, iconW, iconH, pitch);
+
+ img->onClick = onTile;
+ img->userData = (void *)(intptr_t)action;
+ }
+
+ WidgetT *text = wgtVBox(tile);
+ WidgetT *btn = wgtButton(text, a->title);
+
+ text->padding = wgtPixels(0);
+ text->weight = WGT_WEIGHT_FILL;
+ btn->onClick = onTile;
+ btn->userData = (void *)(intptr_t)action;
+ sSetup.tileBtns[action] = btn;
+
+ for (int32_t l = 0; l < SETUP_TILE_LINES && a->lines[l][0]; l++) {
+ wgtLabel(text, a->lines[l]);
+ }
+}
+
+
// The font of a use (the system font when it did not open)
static const BitmapFontT *typeFont(SetupTypeE type) {
return sSetup.fonts[type] ? sSetup.fonts[type] : &sSetup.ctx.font;
}
-// Uninstall confirmed: take the AUTOEXEC.BAT line out (asking), then
-// remove the files.
+// Remove clicked: DVX must be in the folder (its SETUP.LST); take the
+// AUTOEXEC.BAT line out (asking), then remove the files.
static void uninstallRun(void) {
- char path[DVX_MAX_PATH];
- char line[DVX_MAX_PATH];
- char msg[SETUP_MSG_MAX];
- char err[SETUP_ERR_MAX];
- bool removeSettings = wgtCheckboxIsChecked(sSetup.removeSettings);
- const char root[] = { sSetup.destDir[0], ':', DVX_PATH_SEP[0], '\0' };
+ char path[DVX_MAX_PATH];
+ char line[DVX_MAX_PATH];
+ char parent[DVX_MAX_PATH];
+ char msg[SETUP_MSG_MAX];
+ char err[SETUP_ERR_MAX];
+ bool removeSettings = wgtCheckboxIsChecked(sSetup.userBox);
+
+ folderGet(sSetup.destDir, sizeof(sSetup.destDir));
+
+ if (!platformPathIsAbsolute(sSetup.destDir) || !fileOpsPathJoin(path, sizeof(path), sSetup.destDir, SETUP_LIST_NAME) || access(path, F_OK) != 0) {
+ snprintf(msg, sizeof(msg), "DVX is not installed in %s (or it was not installed by this Setup).", sSetup.destDir);
+ dvxErrorBox(&sSetup.ctx, SETUP_TITLE, msg);
+ return;
+ }
if (setupAutoexecPath(platformBootDrive(), path, sizeof(path)) && setupAutoexecLine(sSetup.destDir, line, sizeof(line)) && setupAutoexecHasLine(path, line)) {
snprintf(msg, sizeof(msg), "%s starts DVX with the line\n\n%s\n\nRemove that line? (The file as it is now is saved as AUTOEXEC.DVX first.)", path, line);
@@ -1264,7 +1463,9 @@ static void uninstallRun(void) {
}
// The folder being removed must not be the current one.
- platformChdir(root);
+ if (platformPathDirName(sSetup.destDir, parent, sizeof(parent))) {
+ platformChdir(parent);
+ }
dvxSetBusy(&sSetup.ctx, true);
bool ok = setupUninstall(sSetup.destDir, removeSettings, err, sizeof(err));
@@ -1299,7 +1500,10 @@ int main(int argc, char *argv[]) {
wgtRegisterBox();
wgtRegisterButton();
wgtRegisterCheckbox();
+ wgtRegisterDropdown();
+ wgtRegisterImageButton();
wgtRegisterLabel();
+ wgtRegisterListBox();
wgtRegisterProgress();
wgtRegisterRadio();
wgtRegisterSpacer();
@@ -1313,7 +1517,7 @@ int main(int argc, char *argv[]) {
return SETUP_EXIT_FAILED;
}
- // Without a package Setup can only uninstall the DVX in its own folder.
+ // Without a package Setup can only remove the DVX in its own folder.
char list[DVX_MAX_PATH];
bool listed = fileOpsPathJoin(list, sizeof(list), sSetup.src.partDir, SETUP_LIST_NAME) && access(list, F_OK) == 0;
@@ -1355,37 +1559,41 @@ int main(int argc, char *argv[]) {
// No package: this is the Setup in a DVX folder, which can only
// remove DVX from its own folder.
if (sSetup.src.found == PkgFoundNoneE) {
- char msg[SETUP_MSG_MAX];
-
- snprintf(sSetup.destDir, sizeof(sSetup.destDir), "%s", sSetup.src.partDir);
- snprintf(msg, sizeof(msg), "Setup will remove DVX from %s. To install or repair DVX, run SETUP from the DVX CD or Disk 1.", sSetup.destDir);
- wgtSetText(sSetup.unText, msg);
- wgtSetVisible(sSetup.unBackBtn, false);
- showPage(PageUninstallE);
+ wgtSetText(sSetup.dest, sSetup.src.partDir);
+ wgtSetReadOnly(sSetup.dest, true);
+ wgtSetVisible(sSetup.browseBtn, false);
+ wgtSetVisible(sSetup.backBtn, false);
+ wgtSetVisible(sSetup.ownNote, true);
+ showFolder(ActionRemoveE);
} else {
showPage(PageLicenceE);
}
- // /SHOTS: the pages before Install, as Next shows them
+ // Unattended: install, or upgrade the DVX already there keeping its
+ // settings. /SHOTS: the pages before that, as Next and the tile show
+ // them.
+ if (sSetup.autoMode) {
+ sSetup.action = setupIsInstalled(sSetup.autoDest) ? ActionUpgradeE : ActionInstallE;
+ sSetup.runRequested = true;
+ }
+
if (sSetup.shots) {
shootPage();
onNext(NULL);
shootPage();
+ showFolder(sSetup.action);
+ shootPage();
}
- sSetup.installRequested = sSetup.autoMode;
-
while (!sSetup.quit && dvxUpdate(&sSetup.ctx)) {
- if (sSetup.installRequested) {
- sSetup.installRequested = false;
+ if (sSetup.runRequested && sSetup.action == ActionRemoveE) {
+ sSetup.runRequested = false;
+ uninstallRun();
+ } else if (sSetup.runRequested) {
+ sSetup.runRequested = false;
installRun();
sSetup.quit = sSetup.quit || sSetup.autoMode;
}
-
- if (sSetup.uninstallRequested) {
- sSetup.uninstallRequested = false;
- uninstallRun();
- }
}
dvxShutdown(&sSetup.ctx);
diff --git a/src/installer/setupCore.c b/src/installer/setupCore.c
index 21fc3fdd..76e961c1 100644
--- a/src/installer/setupCore.c
+++ b/src/installer/setupCore.c
@@ -26,6 +26,7 @@
#include "dvxPlat.h"
#include "dvxPrefs.h"
#include "fileops/fileOps.h"
+#include "pkgDb.h"
#include "stb_ds_wrap.h"
#include
@@ -46,11 +47,13 @@
static bool autoexecRewrite(const char *path, const char *text, int32_t len, char *err, int32_t errSize);
static int byLengthDesc(const void *a, const void *b);
+static void dirsAdd(char ***dirs, const char *destDir, const char *path);
static bool lineMatches(char *l, const char *line);
static void listFree(char **names);
static bool listNameIsValid(const char *name);
static char **listRead(const char *destDir);
static bool removeFile(const char *path);
+static int32_t removePackages(const char *destDir, char ***dirs);
bool setupAutoexecAdd(const char *path, const char *line, char *err, int32_t errSize);
bool setupAutoexecHasLine(const char *path, const char *line);
bool setupAutoexecLine(const char *destDir, char *out, int32_t outSize);
@@ -61,14 +64,13 @@ SetupFontsE setupFontsInstalled(const char *destDir, const PkgHeaderT *info);
bool setupHasSettings(const char *destDir);
bool setupIsInstalled(const char *destDir);
bool setupListWrite(const char *destDir, char *const *files, int32_t count);
+bool setupRecordDvx(const char *destDir);
void setupRemoveDropped(const char *destDir, const char *folder, char *const *files, int32_t count);
bool setupRemoveUser(const char *destDir);
void setupRemoveWritten(char *const *written, int32_t writtenCount, char *const *dirs, int32_t dirCount);
uint64_t setupSpaceNeeded(const PkgHeaderT *info, SetupFontsE tier);
bool setupUninstall(const char *destDir, bool removeSettings, char *err, int32_t errSize);
bool setupWriteIni(const char *destDir, int32_t width, int32_t height, int32_t bpp);
-static bool writeAll(FILE *f, const char *data, int32_t len);
-static bool writeReplace(const char *path, const char *text, int32_t len);
// Back up path (when it exists) as its SETUP_AUTOEXEC_BAK sibling, then
@@ -87,7 +89,7 @@ static bool autoexecRewrite(const char *path, const char *text, int32_t len, cha
return false;
}
- if (!writeReplace(path, text, len)) {
+ if (!platformWriteFile(path, text, len)) {
snprintf(err, errSize, "Could not write %s (is the disk full or write-protected?).", path);
return false;
}
@@ -105,6 +107,19 @@ static int byLengthDesc(const void *a, const void *b) {
}
+// Every folder path is in, below destDir, for the empty-folder pass.
+static void dirsAdd(char ***dirs, const char *destDir, const char *path) {
+ char dir[DVX_MAX_PATH];
+
+ snprintf(dir, sizeof(dir), "%s", path);
+
+ for (char *sep = platformPathDirEnd(dir); sep && sep > dir + strlen(destDir); sep = platformPathDirEnd(dir)) {
+ *sep = '\0';
+ arrput(*dirs, strdup(dir));
+ }
+}
+
+
// A line of a batch file (trimmed in place) is line: case ignored, and
// '/' and '\\' alike (platformPathSame), so "C:/DVX/DVX.EXE" written by
// hand is the same line.
@@ -192,6 +207,38 @@ static bool removeFile(const char *path) {
}
+// The add-on packages in destDir (pkgDb.h): their files and records; each
+// one's folders go to dirs. Returns the files that could not be removed.
+static int32_t removePackages(const char *destDir, char ***dirs) {
+ PkgDbT db;
+ char path[DVX_MAX_PATH];
+ int32_t failed = 0;
+
+ if (!pkgDbLoad(&db, destDir)) {
+ return 0;
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(db.pkgs); i++) {
+ const PkgManifestT *m = &db.pkgs[i].manifest;
+
+ for (int32_t f = 0; f < (int32_t)arrlen(m->files); f++) {
+ if (platformPathJoin(path, sizeof(path), destDir, m->files[f].name)) {
+ dirsAdd(dirs, destDir, path);
+ failed += removeFile(path) ? 0 : 1;
+ }
+ }
+
+ if (pkgDbRecordPath(destDir, m->id, path, sizeof(path))) {
+ dirsAdd(dirs, destDir, path);
+ failed += removeFile(path) ? 0 : 1;
+ }
+ }
+
+ pkgDbFree(&db);
+ return failed;
+}
+
+
bool setupAutoexecAdd(const char *path, const char *line, char *err, int32_t errSize) {
int32_t len = 0;
char *old = platformReadFile(path, &len);
@@ -430,13 +477,35 @@ bool setupListWrite(const char *destDir, char *const *files, int32_t count) {
}
}
- ok = ok && writeReplace(path, text ? text : "", (int32_t)arrlen(text));
+ ok = ok && platformWriteFile(path, text ? text : "", (int32_t)arrlen(text));
arrfree(text);
listFree(old);
return ok;
}
+bool setupRecordDvx(const char *destDir) {
+ PkgDbT db;
+ PkgManifestT m;
+ char err[PKG_ERR_MAX];
+ bool ok;
+
+ memset(&db, 0, sizeof(db));
+ snprintf(db.root, sizeof(db.root), "%s", destDir);
+ pkgDvxManifest(&m);
+
+ ok = pkgDbPut(&db, &m, true, NULL, err, sizeof(err));
+
+ if (!ok) {
+ dvxLog("Setup: %s", err);
+ }
+
+ pkgDbFree(&db);
+ pkgManifestFree(&m);
+ return ok;
+}
+
+
void setupRemoveDropped(const char *destDir, const char *folder, char *const *files, int32_t count) {
char **old = listRead(destDir);
char **dirs = NULL;
@@ -528,6 +597,9 @@ bool setupUninstall(const char *destDir, bool removeSettings, char *err, int32_t
return false;
}
+ // The add-ons first: Setup's list has only DVX's own files
+ failed += removePackages(destDir, &dirs);
+
for (int32_t i = 0; i < (int32_t)arrlen(names); i++) {
if (!pkgLocalPath(destDir, names[i], path, sizeof(path))) {
continue;
@@ -542,15 +614,7 @@ bool setupUninstall(const char *destDir, bool removeSettings, char *err, int32_t
continue;
}
- // Every folder the name is in, for the empty-folder pass.
- for (char *sep = platformPathDirEnd(path); sep && sep > path + strlen(destDir); sep = platformPathDirEnd(path)) {
- *sep = '\0';
- arrput(dirs, strdup(path));
- }
-
- if (!pkgLocalPath(destDir, names[i], path, sizeof(path))) {
- continue;
- }
+ dirsAdd(&dirs, destDir, path);
if (!removeFile(path)) {
failed++;
@@ -617,34 +681,3 @@ bool setupWriteIni(const char *destDir, int32_t width, int32_t height, int32_t b
prefsClose(ini);
return ok;
}
-
-
-static bool writeAll(FILE *f, const char *data, int32_t len) {
- return (int32_t)fwrite(data, 1, (size_t)len, f) == len;
-}
-
-
-// Write text to a scratch file and swap it in for path (so a full disk
-// never leaves a cut-off file).
-static bool writeReplace(const char *path, const char *text, int32_t len) {
- char tmp[DVX_MAX_PATH];
- char swap[DVX_MAX_PATH];
-
- if (!platformPathSibling(tmp, sizeof(tmp), path, PLATFORM_TMP_EXT) || !platformPathSibling(swap, sizeof(swap), path, PLATFORM_BAK_EXT)) {
- return false;
- }
-
- FILE *f = fopen(tmp, "wb");
- bool ok = f != NULL && writeAll(f, text, len);
-
- if (f && fclose(f) != 0) {
- ok = false;
- }
-
- if (!ok) {
- remove(tmp);
- return false;
- }
-
- return platformReplaceFile(tmp, path, swap);
-}
diff --git a/src/installer/setupCore.h b/src/installer/setupCore.h
index ae72da32..17269a28 100644
--- a/src/installer/setupCore.h
+++ b/src/installer/setupCore.h
@@ -124,6 +124,11 @@ bool setupHasSettings(const char *destDir);
// there, or run from there) or SETUP.LST (an install partly removed).
bool setupIsInstalled(const char *destDir);
+// Record DVX itself in destDir as the installed package kpunch.dvx (this
+// version and ABI; no files, which SETUP.LST keeps), so add-on packages
+// can need a version of DVX. False when it cannot be written.
+bool setupRecordDvx(const char *destDir);
+
// Remove the user folder of the DVX in destDir (platformUserDirOf), its
// settings and files, when it holds a DVX.INI (a folder that does not is
// left alone, whatever DVXUSER names). False only when removing failed.
@@ -147,9 +152,10 @@ void setupRemoveWritten(char *const *written, int32_t writtenCount, char *co
// Bytes tiers 0 .. tier of the package need on the target disk.
uint64_t setupSpaceNeeded(const PkgHeaderT *info, SetupFontsE tier);
-// Remove DVX from destDir: every file in SETUP.LST, the user folder when
-// removeSettings (setupRemoveUser), the list itself, then every folder
-// left empty, destDir too. Files the user added stay.
+// Remove DVX from destDir: every add-on package's files and records,
+// every file in SETUP.LST, the user folder when removeSettings
+// (setupRemoveUser), the list itself, then every folder left empty,
+// destDir too. Files the user added stay.
// False with a reason in err when the list is missing or a file could
// not be removed (the rest is still removed).
bool setupUninstall(const char *destDir, bool removeSettings, char *err, int32_t errSize);
diff --git a/src/libs/kpunch/audio/audio.dhs b/src/libs/kpunch/audio/audio.dhs
index ca23a74f..5d75ecf8 100644
--- a/src/libs/kpunch/audio/audio.dhs
+++ b/src/libs/kpunch/audio/audio.dhs
@@ -187,7 +187,7 @@ DVX.INI section [audio]:
midi where MIDI music plays: auto (default: the FM chip, else music.lib's software synthesizer), mpu401, fm, software
mpuPort the MPU-401's port, hex (default 330); the port is only looked for with midi = mpu401
mpuMode what is on the MPU-401 (music.lib): gm (default), mt32, or raw
- midiBank the OP2 instrument bank for FM MIDI (music.lib), a path from the DVX folder
+ midiBank the OP2 instrument bank for FM MIDI (music.lib): its name in SHARED\BANKS or the user's BANKS folder (an add-on's as VENDOR\NAME), or a path from the DVX folder; not set, the bundled one (Control Panel's Sounds page picks it)
.endtable
A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, volume) writes all three (an empty driver stores auto, a midi index out of range stores auto). audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.
diff --git a/src/libs/kpunch/audio/audioBas.c b/src/libs/kpunch/audio/audioBas.c
index 7586f457..d76fa541 100644
--- a/src/libs/kpunch/audio/audioBas.c
+++ b/src/libs/kpunch/audio/audioBas.c
@@ -37,12 +37,11 @@
// (pointer and byte count), in the sound's or stream's own format.
#include "audioInt.h"
+#include "dvxBas.h"
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
#define PERCENT_MAX 100
#define BAS_INFO_MAX (AUDIO_DESC_MAX + 48)
#define BAS_SLOTS 64
diff --git a/src/libs/kpunch/conn/connBas.c b/src/libs/kpunch/conn/connBas.c
index 2a99fc1c..a3c28650 100644
--- a/src/libs/kpunch/conn/connBas.c
+++ b/src/libs/kpunch/conn/connBas.c
@@ -48,13 +48,13 @@
// functions.
#include "conn.h"
-#include "dvxMem.h"
#include "dvxPlat.h"
#include "dvxWgt.h"
#include "ansiTerm/ansiTerm.h"
#include "stb_ds_wrap.h"
#include "../xfer/xfer.h"
#include "../serial/rs232/rs232.h"
+#include "dvxBas.h"
#include
#include
@@ -62,9 +62,6 @@
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define BAS_COM_FIRST 1 // BASIC numbers COM ports from 1
#define READ_MAX 4096 // received bytes moved to the screen per ConnPoll
#define ANSWERBACK_MAX 64
@@ -72,7 +69,6 @@
#define FILE_LIST_SEP '\n'
#define FILE_LIST_CR '\r'
#define ENQ 0x05
-#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
#define TERM_BIND_SYM "basFormRtTermBind"
#define TERM_FORGET_SYM "basFormRtTermForget"
#define TERM_UNBIND_SYM "basFormRtTermUnbind"
@@ -94,35 +90,23 @@ typedef struct {
FILE *capture; // received bytes are appended here
XferDetectT detect;
int32_t xfer; // xfer.bas handle of the transfer on the line (0 = none)
- int32_t appId; // owner: closed when it ends
bool echo;
bool zmodemSeen;
char answerback[ANSWERBACK_MAX]; // reply to ENQ (empty = none)
} BasConnT;
-// The shell's app-cleanup registry and the form runtime's Terminal
-// binding (shellApp.h, formrt.h), looked up at run time: the library
-// loads before both, and a program without forms has no form runtime.
-typedef void (*BasCleanupFnT)(int32_t appId);
-typedef void (*BasCleanupRegFnT)(BasCleanupFnT fn);
+// The form runtime's Terminal binding (formrt.h), looked up at run
+// time: the library loads before it, and a program without forms has no
+// form runtime.
typedef int32_t (*BasTermWriteFnT)(void *ctx, const uint8_t *data, int32_t len);
typedef void (*BasTermUnbindFnT)(void *ctx);
typedef WidgetT *(*BasTermBindFnT)(const char *ctrlName, void *ctx, BasTermWriteFnT writeFn, BasTermUnbindFnT onUnbind);
typedef void (*BasTermCtxFnT)(void *ctx);
-// ============================================================
-// Module state
-// ============================================================
-
-static BasConnT **sConns = NULL; // stb_ds; handle = index + 1, NULL = free
-static bool sCleanupHooked = false;
-static uint8_t sReadBuf[READ_MAX]; // ConnPoll / ConnRead$ (never re-entered)
-
// ============================================================
// Prototypes
// ============================================================
-static void appEnded(int32_t appId);
int32_t ConnAttach(int32_t handle, const char *termCtrlName);
int32_t ConnCanBreak(int32_t type);
int32_t ConnCapture(int32_t handle, const char *path);
@@ -131,6 +115,7 @@ int32_t ConnCreate(void);
void ConnDetach(int32_t handle);
const char *ConnErrorText(int32_t err);
void ConnFree(int32_t handle);
+static void connFree(void *obj);
static BasConnT *connOf(int32_t handle);
int32_t ConnOpen(int32_t handle, int32_t type);
int32_t ConnPending(int32_t handle);
@@ -153,7 +138,6 @@ int32_t ConnStatusSerial(int32_t handle);
int32_t ConnWrite(int32_t handle, const uint8_t *data, int32_t len);
int32_t ConnZmodemRequest(int32_t handle);
static void echoLocal(BasConnT *b, const uint8_t *data, int32_t len);
-static void freeSlot(int32_t handle, bool termAlive);
static uint32_t nowMs(void);
static XferT *runningTransfer(BasConnT *b);
static void showIncoming(BasConnT *b);
@@ -165,16 +149,12 @@ static int32_t xferPendingFn(void *ctx);
static int32_t xferReadFn(void *ctx, uint8_t *buf, int32_t maxLen);
static int32_t xferWriteFn(void *ctx, const uint8_t *data, int32_t len);
+// ============================================================
+// Module state
+// ============================================================
-// Shell app-cleanup hook: close what a program that ended left open. Its
-// windows are already gone, so a Terminal binding is only forgotten.
-static void appEnded(int32_t appId) {
- for (int32_t i = 0; i < (int32_t)arrlen(sConns); i++) {
- if (sConns[i] && sConns[i]->appId == appId) {
- freeSlot(i + 1, false);
- }
- }
-}
+static BasHandlesT sConns = BAS_HANDLES(connFree, 0); // the open connections
+static uint8_t sReadBuf[READ_MAX]; // ConnPoll / ConnRead$ (never re-entered)
// Show the connection on the Terminal control named termCtrlName (on any
@@ -235,17 +215,8 @@ void ConnClose(int32_t handle) {
// A closed connection with the defaults (serial COM1, 9600 8N1, no flow
// control, "ATZ", "ATDT", port 23). 0 when out of memory.
int32_t ConnCreate(void) {
- if (!sCleanupHooked) {
- BasCleanupRegFnT reg = (BasCleanupRegFnT)dlsym(NULL, DVX_SYM(SHELL_CLEANUP_SYM));
-
- sCleanupHooked = true;
-
- if (reg) {
- reg(appEnded);
- }
- }
-
BasConnT *b = (BasConnT *)calloc(1, sizeof(BasConnT));
+ int32_t handle;
if (!b) {
return BAS_NO_HANDLE;
@@ -259,22 +230,14 @@ int32_t ConnCreate(void) {
}
connConfigDefaults(&b->cfg);
- b->appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
b->xferWindow = RX_WINDOW_AUTO;
+ handle = basHandleAdd(&sConns, b);
- // Reuse the first free handle, else a new one
- int32_t idx = 0;
-
- while (idx < (int32_t)arrlen(sConns) && sConns[idx]) {
- idx++;
+ if (handle == BAS_NO_HANDLE) {
+ connFree(b);
}
- if (idx == (int32_t)arrlen(sConns)) {
- arrput(sConns, NULL);
- }
-
- sConns[idx] = b;
- return idx + 1;
+ return handle;
}
@@ -304,18 +267,42 @@ const char *ConnErrorText(int32_t err) {
}
+// Its Terminal still there (the program asks): the binding is undone on
+// the widget first.
void ConnFree(int32_t handle) {
- freeSlot(handle, true);
+ ConnDetach(handle);
+ basHandleFree(&sConns, handle);
+}
+
+
+// A connection (the handles' freeFn): its Terminal binding forgotten
+// without touching the widget (the program ended and its windows are
+// gone; ConnFree detaches it first), its transfer cancelled, its capture
+// file closed.
+static void connFree(void *obj) {
+ BasConnT *b = (BasConnT *)obj;
+ XferT *x = NULL;
+
+ termForget(b);
+ x = runningTransfer(b);
+
+ if (x) {
+ xferCancel(x);
+ }
+
+ if (b->capture) {
+ fclose(b->capture);
+ }
+
+ connDestroy(b->conn);
+ memset(b, 0, sizeof(*b));
+ free(b);
}
// The connection of a handle, or NULL.
static BasConnT *connOf(int32_t handle) {
- if (handle <= BAS_NO_HANDLE || handle > (int32_t)arrlen(sConns)) {
- return NULL;
- }
-
- return sConns[handle - 1];
+ return (BasConnT *)basHandleGet(&sConns, handle);
}
@@ -585,40 +572,6 @@ static void echoLocal(BasConnT *b, const uint8_t *data, int32_t len) {
}
-// Close and free a handle's connection. termAlive: its Terminal still
-// exists (the program asked), so the binding is undone on the widget;
-// otherwise it is only forgotten.
-static void freeSlot(int32_t handle, bool termAlive) {
- BasConnT *b = connOf(handle);
- XferT *x;
-
- if (!b) {
- return;
- }
-
- if (termAlive) {
- ConnDetach(handle);
- } else {
- termForget(b);
- }
-
- x = runningTransfer(b);
-
- if (x) {
- xferCancel(x);
- }
-
- if (b->capture) {
- fclose(b->capture);
- }
-
- connDestroy(b->conn);
- memset(b, 0, sizeof(*b));
- free(b);
- sConns[handle - 1] = NULL;
-}
-
-
static uint32_t nowMs(void) {
return PLATFORM_TICKS_TO_MS(platformClock());
}
diff --git a/src/libs/kpunch/dvxnet/netBas.c b/src/libs/kpunch/dvxnet/netBas.c
index 7f2c9169..a9f08b8b 100644
--- a/src/libs/kpunch/dvxnet/netBas.c
+++ b/src/libs/kpunch/dvxnet/netBas.c
@@ -40,6 +40,7 @@
#include "netInt.h"
#include "dvxTypes.h"
+#include "dvxBas.h"
#include
#include
@@ -62,8 +63,6 @@
#define BAS_BACKLOG 4
#define BAS_ENDPOINT_MAX (INET_ADDRSTRLEN + 8)
#define BAS_PORT_MAX 65535
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
#define BAS_CONFIG_MAX 16
#define BAS_FIELD_MAX NET_MESSAGE_MAX
#define BAS_DNS_PREFIX "dns"
diff --git a/src/libs/kpunch/dvxshell/Makefile b/src/libs/kpunch/dvxshell/Makefile
index 3265d6fc..967aa215 100644
--- a/src/libs/kpunch/dvxshell/Makefile
+++ b/src/libs/kpunch/dvxshell/Makefile
@@ -34,6 +34,7 @@ SYSTEMDIR = ../../../../bin/system
SHAREDDIR = ../../../../bin/shared
THEMEDIR = $(SHAREDDIR)/themes
WPAPERDIR = $(SHAREDDIR)/wpaper
+KEYSDIR = $(SYSTEMDIR)/KEYS
SRCS = shellMain.c shellApp.c shellBas.c shellSysBas.c shellInfo.c shellAssoc.c shellProg.c shellRun.c pmModel.c pmModelBas.c
OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
@@ -45,7 +46,11 @@ TARGET = $(TARGETDIR)/dvxshell.lib
THEMES = $(THEMEDIR)/geos.thm $(THEMEDIR)/win31.thm $(THEMEDIR)/cde.thm $(THEMEDIR)/hotdog.thm
WPAPERS = $(WPAPERDIR)/blueglow.jpg $(WPAPERDIR)/swoop.jpg $(WPAPERDIR)/triangle.jpg
-all: $(TARGET) $(TARGETDIR)/dvxshell.dep $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI $(THEMES) $(WPAPERS)
+# The repository's public keys (config/keys/*.KEY, made offline with
+# dvxpkg keygen): what DVX trusts as its own (pkgTrust.h)
+KEYS = $(patsubst ../../../../config/keys/%,$(KEYSDIR)/%,$(wildcard ../../../../config/keys/*.KEY))
+
+all: $(TARGET) $(TARGETDIR)/dvxshell.dep $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI $(THEMES) $(WPAPERS) $(KEYS)
$(TARGETDIR)/dvxshell.dep: dvxshell.dep | $(TARGETDIR)
sed 's/$$/\r/' $< > $@
@@ -86,8 +91,14 @@ $(WPAPERDIR):
$(WPAPERDIR)/%.jpg: ../../../../config/wpaper/%.jpg | $(WPAPERDIR)
cp $< $@
+$(KEYSDIR):
+ mkdir -p $(KEYSDIR)
+
+$(KEYSDIR)/%.KEY: ../../../../config/keys/%.KEY | $(KEYSDIR)
+ cp $< $@
+
-include $(OBJS:.o=.d)
clean:
rm -rf $(OBJS) $(OBJS:.o=.d) $(TARGET) $(TARGETDIR)/dvxshell.dep $(TARGETDIR)
- rm -rf $(WPAPERDIR) $(THEMEDIR) $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI
+ rm -rf $(WPAPERDIR) $(THEMEDIR) $(KEYSDIR) $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI
diff --git a/src/libs/kpunch/dvxshell/pmModelBas.c b/src/libs/kpunch/dvxshell/pmModelBas.c
index 3a168726..aa3d5670 100644
--- a/src/libs/kpunch/dvxshell/pmModelBas.c
+++ b/src/libs/kpunch/dvxshell/pmModelBas.c
@@ -39,36 +39,19 @@
#include
#include
#include
-#include "dvxMem.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define BAS_NO_INDEX (-1)
#define BAS_LINE_SEP '\n' // between the lines of ShellGroupsHistory$
#define FIELD_TEXT_MAX DVX_MAX_PATH
-// One open groups file and the program it belongs to (m NULL: free slot)
-typedef struct {
- PmModelT *m;
- int32_t appId;
-} BasGroupsT;
-
-// ============================================================
-// Module state
-// ============================================================
-
-static BasGroupsT *sGroups = NULL; // stb_ds; handle = index + 1
-static char *sText = NULL; // stb_ds: the last string result
-
// ============================================================
// Prototypes
// ============================================================
-static void appEnded(int32_t appId);
-static int32_t currentAppId(void);
static PmGroupT *groupOf(int32_t handle, int32_t g, PmModelT **outModel);
+static void groupsFree(void *obj);
static PmItemT *itemOf(int32_t handle, int32_t g, int32_t i);
static PmModelT *modelOf(int32_t handle);
int32_t ShellGroupAdd(int32_t handle, const char *name);
@@ -101,25 +84,13 @@ int32_t ShellItemMove(int32_t handle, int32_t g, int32_t i, int32_t d
void ShellItemPlace(int32_t handle, int32_t g, int32_t i, int32_t col, int32_t row);
int32_t ShellItemReorder(int32_t handle, int32_t g, int32_t from, int32_t to);
int32_t ShellItemSet(int32_t handle, int32_t g, int32_t i, const char *field, const char *value);
-static const char *textOf(const char *s);
+// ============================================================
+// Module state
+// ============================================================
-// Shell app-cleanup hook: free the groups files a program that ended
-// left open.
-static void appEnded(int32_t appId) {
- for (int32_t i = 0; i < (int32_t)arrlen(sGroups); i++) {
- if (sGroups[i].m && sGroups[i].appId == appId) {
- ShellGroupsFree(i + 1);
- }
- }
-}
-
-
-static int32_t currentAppId(void) {
- AppContextT *ctx = shellContext();
-
- return ctx ? ctx->currentAppId : 0;
-}
+static BasHandlesT sGroups = BAS_HANDLES(groupsFree, 0); // the open groups files
+static BasTextT sText; // the last string result
// Group g of an open groups file (NULL for none); *outModel (may be NULL)
@@ -135,6 +106,13 @@ static PmGroupT *groupOf(int32_t handle, int32_t g, PmModelT **outModel) {
}
+// A groups file's model (the handles' freeFn).
+static void groupsFree(void *obj) {
+ pmModelFree((PmModelT *)obj);
+ free(obj);
+}
+
+
static PmItemT *itemOf(int32_t handle, int32_t g, int32_t i) {
PmGroupT *grp = groupOf(handle, g, NULL);
@@ -143,7 +121,7 @@ static PmItemT *itemOf(int32_t handle, int32_t g, int32_t i) {
static PmModelT *modelOf(int32_t handle) {
- return (handle >= 1 && handle <= (int32_t)arrlen(sGroups)) ? sGroups[handle - 1].m : NULL;
+ return (PmModelT *)basHandleGet(&sGroups, handle);
}
@@ -230,13 +208,7 @@ int32_t ShellGroupsChanged(int32_t handle, const char *skipPath) {
void ShellGroupsFree(int32_t handle) {
- PmModelT *m = modelOf(handle);
-
- if (m) {
- pmModelFree(m);
- free(m);
- sGroups[handle - 1].m = NULL;
- }
+ basHandleFree(&sGroups, handle);
}
@@ -258,22 +230,17 @@ int32_t ShellGroupsGet(int32_t handle, const char *option) {
// The Run history, newest first, one command per line: what
// ShellRunDialog$ takes.
const char *ShellGroupsHistory(int32_t handle) {
- PmModelT *m = modelOf(handle);
+ PmModelT *m = modelOf(handle);
+ const char sep[] = { BAS_LINE_SEP, '\0' };
- arrfree(sText); // a new result: the last one is gone
+ basTextStart(&sText);
for (int32_t i = 0; m && i < (int32_t)arrlen(m->history); i++) {
- if (i > 0) {
- arrput(sText, BAS_LINE_SEP);
- }
-
- for (const char *c = m->history[i]; *c; c++) {
- arrput(sText, *c);
- }
+ basTextAppend(&sText, i > 0 ? sep : "");
+ basTextAppend(&sText, m->history[i]);
}
- arrput(sText, '\0');
- return sText;
+ return basTextEnd(&sText);
}
@@ -303,22 +270,13 @@ int32_t ShellGroupsLoad(const char *path) {
return BAS_NO_HANDLE;
}
- int32_t idx = 0;
+ int32_t handle = basHandleAdd(&sGroups, m);
- while (idx < (int32_t)arrlen(sGroups) && sGroups[idx].m) {
- idx++;
+ if (handle == BAS_NO_HANDLE) {
+ groupsFree(m);
}
- if (idx == (int32_t)arrlen(sGroups)) {
- BasGroupsT blank = { NULL, 0 };
-
- arrput(sGroups, blank);
- }
-
- sGroups[idx].m = m;
- sGroups[idx].appId = currentAppId();
- shellRegisterAppCleanup(appEnded);
- return idx + 1;
+ return handle;
}
@@ -393,7 +351,7 @@ const char *ShellItemGet(int32_t handle, int32_t g, int32_t i, const char *field
pmItemGetField(item, field, text, (int32_t)sizeof(text));
}
- return textOf(text);
+ return basTextSet(&sText, text);
}
@@ -452,15 +410,3 @@ int32_t ShellItemSet(int32_t handle, int32_t g, int32_t i, const char *field, co
return pmItemSetField(item, field, value) ? BAS_TRUE : BAS_FALSE;
}
-
-
-static const char *textOf(const char *s) {
- arrfree(sText); // a new result: the last one is gone
-
- for (const char *c = s; *c; c++) {
- arrput(sText, *c);
- }
-
- arrput(sText, '\0');
- return sText;
-}
diff --git a/src/libs/kpunch/dvxshell/shellBas.c b/src/libs/kpunch/dvxshell/shellBas.c
index 15f12d79..d311f7e3 100644
--- a/src/libs/kpunch/dvxshell/shellBas.c
+++ b/src/libs/kpunch/dvxshell/shellBas.c
@@ -38,15 +38,13 @@
#include "dvxPlat.h"
#include "dvxPrefs.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define BAS_HISTORY_SEP '\n'
#define BAS_LINE_END '\r'
#define HOTKEY_QUEUE_MAX 16 // presses kept per program that has not asked yet
diff --git a/src/libs/kpunch/dvxshell/shellMain.c b/src/libs/kpunch/dvxshell/shellMain.c
index 9d036253..41e08125 100644
--- a/src/libs/kpunch/dvxshell/shellMain.c
+++ b/src/libs/kpunch/dvxshell/shellMain.c
@@ -102,11 +102,12 @@ DVX_ABI_STAMP(dvxshell);
#define SHELL_STUCK_EXIT_ASK "'%s' is not responding to the request to close. End it and continue exiting DVX?"
#define SHELL_STUCK_CLOSE_ASK "'%s' is not responding to the request to close. End it?"
-// Command-line switches: start in safe video mode; start a program once
-// the desktop is up (DVX /RUN=APPS\KPUNCH\NOTEPAD\NOTEPAD.APP)
-#define SHELL_SWITCH_SAFE "/SAFE"
+// Command-line switch: start a program once the desktop is up
+// (DVX /RUN=APPS\KPUNCH\NOTEPAD\NOTEPAD.APP); DVX_SWITCH_SAFE is the other
#define SHELL_SWITCH_RUN "/RUN="
#define SHELL_SAFE_TITLE "Safe Video Mode"
+#define SHELL_ADDON_TITLE "Add-ons"
+#define SHELL_ADDON_ADVICE "\nThe Software program can reinstall or remove an add-on."
#define SHELL_READ_ONLY_MSG "DVX cannot save anything: it is running from a disk it cannot write to, such as a CD-ROM, and there is no TEMP folder. Settings you change are kept until you exit DVX. To keep them, set DVXUSER to a folder on your hard disk before starting DVX, or install DVX with SETUP."
#define SHELL_USER_TEMP_MSG "DVX is running from a disk it cannot write to, such as a CD-ROM, so your settings are kept in the TEMP folder. To keep them somewhere else, set DVXUSER to a folder on your hard disk before starting DVX."
#define SHELL_NO_VIDEO_MSG "DVX could not start: the video card offered no usable graphics mode. DVX needs a VESA video BIOS with a mode of at least 640x480 at 256 colors or more. Many older cards need their VESA driver loaded first: UniVBE (Scitech Display Doctor) works with most cards, and some cards came with their own, such as S3VBE20 for S3 cards and M64VBE for ATI Mach64 cards. The details are in DVX.LOG in the USER folder."
@@ -215,6 +216,7 @@ static uint32_t sDesktopChanges = 0;
static void appExitHook(int status);
static void appIdRestore(int32_t value);
static int32_t appIdSave(void);
+static void bootNotesShow(void);
static bool closeRefusedForDialog(AppContextT *ctx);
static void compactDesktopFns(void);
static void compactIdleHandlers(void);
@@ -294,6 +296,31 @@ static int32_t appIdSave(void) {
}
+// What the loader had to say (platformBootNotes): the add-ons it could
+// not load, or that a safe start left them out. A blank line between
+// notes, so each stands apart once wrapped.
+static void bootNotesShow(void) {
+ char msg[PLATFORM_BOOT_NOTES_MAX * 2 + sizeof(SHELL_ADDON_ADVICE)];
+ const char *notes = platformBootNotes();
+ int32_t len = 0;
+
+ if (!notes[0]) {
+ return;
+ }
+
+ for (const char *p = notes; *p; p++) {
+ msg[len++] = *p;
+
+ if (*p == '\n') {
+ msg[len++] = '\n';
+ }
+ }
+
+ snprintf(msg + len - 1, sizeof(msg) - (size_t)len + 1, "%s", SHELL_ADDON_ADVICE);
+ dvxInfoBox(&sCtx, SHELL_ADDON_TITLE, msg);
+}
+
+
// A dialog box still open (its nested event loop is on some stack) is
// raised and refuses closing programs, so it can be answered first.
static bool closeRefusedForDialog(AppContextT *ctx) {
@@ -746,7 +773,7 @@ int shellMain(int argc, char *argv[]) {
int32_t videoW;
int32_t videoH;
int32_t videoBpp;
- bool safeAsked = findSwitch(argc, argv, SHELL_SWITCH_SAFE) || (platformKeyboardGetModifiers() & KEY_MOD_SHIFT);
+ bool safeAsked = findSwitch(argc, argv, DVX_SWITCH_SAFE) || (platformKeyboardGetModifiers() & KEY_MOD_SHIFT);
VideoStartE videoHow;
prefsGetVideoMode(sPrefs, &videoW, &videoH, &videoBpp);
@@ -832,6 +859,8 @@ int shellMain(int argc, char *argv[]) {
dvxInfoBox(&sCtx, NULL, SHELL_USER_TEMP_MSG);
}
+ bootNotesShow();
+
// Then the Welcome window while it is wanted, and a /RUN= program.
const char *welcome = prefsGetString(sPrefs, SHELL_INI_SECTION, SHELL_INI_WELCOME, "");
const char *runProg = findSwitch(argc, argv, SHELL_SWITCH_RUN);
diff --git a/src/libs/kpunch/dvxshell/shellSysBas.c b/src/libs/kpunch/dvxshell/shellSysBas.c
index c77766a5..37e79993 100644
--- a/src/libs/kpunch/dvxshell/shellSysBas.c
+++ b/src/libs/kpunch/dvxshell/shellSysBas.c
@@ -44,14 +44,13 @@
#include "dvxWgt.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
#define MEMORY_UNKNOWN (-1)
#define KEY_BYTE_MASK 0xFF // the character / scan code byte of a key code
#define SYS_SEND_KEY_MAX 64 // keys SysSendKey keeps waiting
diff --git a/src/libs/kpunch/fileops/fileOpsBas.c b/src/libs/kpunch/fileops/fileOpsBas.c
index a2f77a97..bad36a18 100644
--- a/src/libs/kpunch/fileops/fileOpsBas.c
+++ b/src/libs/kpunch/fileops/fileOpsBas.c
@@ -35,12 +35,12 @@
// and removes a partly copied one.
#include "fileOps.h"
-#include "dvxMem.h"
#include "dvxPlat.h"
+#include "dvxTypes.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
-#include
#include
#include
#include
@@ -48,78 +48,53 @@
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define FREE_UNKNOWN (-1)
#define DRIVE_UNKNOWN (-1)
#define ATTR_UNKNOWN (-1)
#define LIST_NOT_FOUND (-1)
#define BYTES_PER_KB 1024
#define DRIVE_TEXT_MAX 2 // a drive letter and its NUL
-#define ROW_CELL_SEP '\t' // between the cells of a FileOpsListRows$ row
-#define ROW_END '\n' // after each row
-#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
typedef struct {
bool yesToAll; // replace existing / delete read-only files
int32_t problems; // errors that skipped an item
} BasRunT;
-// One handle: a listing or a job, and the program it belongs to.
+// One handle: a listing or a job.
typedef struct {
FileOpsEntryT *entries; // listing (NULL for a job)
FileOpsJobT *job; // job (NULL for a listing)
int32_t count; // entries in the listing
- int32_t appId; // owner: freed when it ends
- bool used;
} BasHandleT;
-// The shell's app-cleanup registry (shellApp.h), looked up at run time:
-// the library loads before the shell, and the host tests have none.
-typedef void (*BasCleanupFnT)(int32_t appId);
-typedef void (*BasCleanupRegFnT)(BasCleanupFnT fn);
-
// ============================================================
// Module state
// ============================================================
-static BasHandleT *sHandles = NULL; // stb_ds; handle = index + 1
-static bool sCleanupHooked = false;
-static char sError[FILEOPS_MSG_MAX]; // why the last MakeDir / Rename / ReplaceFile / Valid* failed
-static char *sText = NULL; // stb_ds: the last string result
-static uint64_t sTreeBytes = 0; // the last FileOpsTreeSize
-static int32_t sTreeFiles = 0;
+static char sError[FILEOPS_MSG_MAX]; // why the last MakeDir / Rename / ReplaceFile / Valid* failed
+static uint64_t sTreeBytes = 0; // the last FileOpsTreeSize
+static int32_t sTreeFiles = 0;
static int32_t sTreeFolders = 0;
// ============================================================
// Prototypes
// ============================================================
-static void appEnded(int32_t appId);
static FileOpsAnswerE basAnswer(FileOpsJobT *job, FileOpsStepE kind, const char *message, void *ctx);
static double diskBytes(const char *path, bool total);
+static void handleFree(void *obj);
static int32_t handleNew(FileOpsEntryT *entries, int32_t count, FileOpsJobT *job);
static BasHandleT *handleOf(int32_t handle);
-static void handleRelease(BasHandleT *hd);
static FileOpsJobT *jobOf(int32_t job);
static bool jobProgress(int32_t job, FileOpsProgressT *p);
static const FileOpsEntryT *listEntry(int32_t list, int32_t index);
static BasHandleT *listOf(int32_t list);
static int32_t nameResult(const char *problem);
static int32_t runTree(FileOpsOpE op, const char *src, const char *destDir, bool yesToAll);
-static void textAppend(const char *s);
-static const char *textOf(const char *s);
-
-// Shell app-cleanup hook: free what a program that ended left open.
-static void appEnded(int32_t appId) {
- for (int32_t i = 0; i < (int32_t)arrlen(sHandles); i++) {
- if (sHandles[i].used && sHandles[i].appId == appId) {
- handleRelease(&sHandles[i]);
- }
- }
-}
+// The listings and jobs, and the last string result
+static BasHandlesT sHandles = BAS_HANDLES(handleFree, 0);
+static BasTextT sText;
// Questions get the caller's overwrite choice; errors are counted and the
@@ -154,7 +129,7 @@ static double diskBytes(const char *path, bool total) {
const char *FileOpsBaseName(const char *path) {
- return textOf(platformPathBaseName(path));
+ return basTextSet(&sText, platformPathBaseName(path));
}
@@ -216,7 +191,7 @@ const char *FileOpsError(void) {
const char *FileOpsExtension(const char *name) {
- return textOf(fileOpsExtension(name));
+ return basTextSet(&sText, fileOpsExtension(name));
}
@@ -232,7 +207,7 @@ const char *FileOpsFormatSize(double bytes) {
char text[FILEOPS_SIZE_TEXT_MAX];
fileOpsFormatSize(bytes > 0 ? (uint64_t)bytes : 0, text, sizeof(text));
- return textOf(text);
+ return basTextSet(&sText, text);
}
@@ -354,7 +329,7 @@ int32_t FileOpsJobCreate(int32_t op, const char *destDir) {
const char *FileOpsJobCurrent(int32_t job) {
FileOpsProgressT p;
- return jobProgress(job, &p) ? textOf(p.current) : "";
+ return jobProgress(job, &p) ? basTextSet(&sText, p.current) : "";
}
@@ -375,10 +350,8 @@ int32_t FileOpsJobFilesTotal(int32_t job) {
void FileOpsJobFree(int32_t job) {
- BasHandleT *hd = handleOf(job);
-
- if (hd && hd->job) {
- handleRelease(hd);
+ if (jobOf(job)) {
+ basHandleFree(&sHandles, job);
}
}
@@ -386,7 +359,7 @@ void FileOpsJobFree(int32_t job) {
const char *FileOpsJobMessage(int32_t job) {
FileOpsJobT *j = jobOf(job);
- return j ? textOf(fileOpsJobMessage(j)) : "";
+ return j ? basTextSet(&sText, fileOpsJobMessage(j)) : "";
}
@@ -424,7 +397,7 @@ int32_t FileOpsJobRunFor(int32_t job, int32_t milliseconds) {
const char *FileOpsKindBrowseDir(int32_t kind) {
char dir[DVX_MAX_PATH];
- return platformKindBrowseDir(dir, sizeof(dir), (DvxKindE)kind) ? textOf(dir) : "";
+ return platformKindBrowseDir(dir, sizeof(dir), (DvxKindE)kind) ? basTextSet(&sText, dir) : "";
}
@@ -433,7 +406,7 @@ const char *FileOpsKindBrowseDir(int32_t kind) {
const char *FileOpsKindDir(int32_t kind, int32_t user) {
char dir[DVX_MAX_PATH];
- return platformKindDir(dir, sizeof(dir), (DvxKindE)kind, user != BAS_FALSE) ? textOf(dir) : "";
+ return platformKindDir(dir, sizeof(dir), (DvxKindE)kind, user != BAS_FALSE) ? basTextSet(&sText, dir) : "";
}
@@ -441,7 +414,7 @@ const char *FileOpsKindDir(int32_t kind, int32_t user) {
const char *FileOpsKindFind(int32_t kind, const char *name) {
char path[DVX_MAX_PATH];
- return platformKindFind(path, sizeof(path), (DvxKindE)kind, name) ? textOf(path) : "";
+ return platformKindFind(path, sizeof(path), (DvxKindE)kind, name) ? basTextSet(&sText, path) : "";
}
@@ -493,7 +466,7 @@ int32_t FileOpsKindList(int32_t kind) {
const char *FileOpsKindName(int32_t kind, const char *path) {
char name[DVX_MAX_PATH];
- return platformKindNameOf((DvxKindE)kind, path, name, sizeof(name)) ? textOf(name) : "";
+ return platformKindNameOf((DvxKindE)kind, path, name, sizeof(name)) ? basTextSet(&sText, name) : "";
}
@@ -541,10 +514,8 @@ int32_t FileOpsListFind(int32_t list, const char *name) {
void FileOpsListFree(int32_t list) {
- BasHandleT *hd = listOf(list);
-
- if (hd) {
- handleRelease(hd);
+ if (listOf(list)) {
+ basHandleFree(&sHandles, list);
}
}
@@ -559,7 +530,7 @@ int32_t FileOpsListIsFolder(int32_t list, int32_t index) {
const char *FileOpsListName(int32_t list, int32_t index) {
const FileOpsEntryT *e = listEntry(list, index);
- return e ? textOf(e->name) : "";
+ return e ? basTextSet(&sText, e->name) : "";
}
@@ -570,15 +541,14 @@ const char *FileOpsListName(int32_t list, int32_t index) {
const char *FileOpsListRows(int32_t list) {
BasHandleT *hd = listOf(list);
- arrsetlen(sText, 0);
+ basTextStart(&sText);
for (int32_t i = 0; hd && i < hd->count; i++) {
const FileOpsEntryT *e = &hd->entries[i];
char size[FILEOPS_SIZE_TEXT_MAX];
char type[FILEOPS_TYPE_TEXT_MAX];
char date[FILEOPS_DATE_TEXT_MAX];
- const char sep[] = { ROW_CELL_SEP, '\0' };
- const char end[] = { ROW_END, '\0' };
+ const char *cells[] = { e->name, size, type, date };
size[0] = '\0';
@@ -589,18 +559,10 @@ const char *FileOpsListRows(int32_t list) {
fileOpsTypeName(e, type, sizeof(type));
fileOpsFormatDate(e->mtime, date, sizeof(date));
- textAppend(e->name);
- textAppend(sep);
- textAppend(size);
- textAppend(sep);
- textAppend(type);
- textAppend(sep);
- textAppend(date);
- textAppend(end);
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
}
- arrput(sText, '\0');
- return sText;
+ return basTextEnd(&sText);
}
@@ -651,7 +613,7 @@ const char *FileOpsModified(const char *path) {
}
fileOpsFormatDate(st.st_mtime, date, sizeof(date));
- return textOf(date);
+ return basTextSet(&sText, date);
}
@@ -678,7 +640,7 @@ int32_t FileOpsPathIsRoot(const char *path) {
const char *FileOpsPathJoin(const char *dir, const char *name) {
char path[DVX_MAX_PATH];
- return fileOpsPathJoin(path, sizeof(path), dir, name) ? textOf(path) : "";
+ return fileOpsPathJoin(path, sizeof(path), dir, name) ? basTextSet(&sText, path) : "";
}
@@ -687,21 +649,21 @@ const char *FileOpsPathJoin(const char *dir, const char *name) {
const char *FileOpsPathNative(const char *path) {
char native[DVX_MAX_PATH];
- return (path && snprintf(native, sizeof(native), "%s", path) < (int)sizeof(native)) ? textOf(platformPathNative(native)) : "";
+ return (path && snprintf(native, sizeof(native), "%s", path) < (int)sizeof(native)) ? basTextSet(&sText, platformPathNative(native)) : "";
}
const char *FileOpsPathNormalize(const char *path) {
char norm[DVX_MAX_PATH];
- return fileOpsPathNormalize(path, norm, sizeof(norm)) ? textOf(norm) : "";
+ return fileOpsPathNormalize(path, norm, sizeof(norm)) ? basTextSet(&sText, norm) : "";
}
const char *FileOpsPathParent(const char *path) {
char parent[DVX_MAX_PATH];
- return fileOpsPathParent(path, parent, sizeof(parent)) ? textOf(parent) : "";
+ return fileOpsPathParent(path, parent, sizeof(parent)) ? basTextSet(&sText, parent) : "";
}
@@ -767,7 +729,7 @@ int32_t FileOpsSetModified(const char *path, const char *stamp) {
const char *FileOpsShortPath(const char *path) {
char shortPath[DVX_MAX_PATH];
- return (path && platformShortPath(path, shortPath, sizeof(shortPath))) ? textOf(shortPath) : "";
+ return (path && platformShortPath(path, shortPath, sizeof(shortPath))) ? basTextSet(&sText, shortPath) : "";
}
@@ -785,7 +747,7 @@ const char *FileOpsTempDir(void) {
const char *FileOpsTempPath(const char *path) {
char tmpPath[DVX_MAX_PATH];
- return platformPathSibling(tmpPath, sizeof(tmpPath), path, PLATFORM_TMP_EXT) ? textOf(tmpPath) : "";
+ return platformPathSibling(tmpPath, sizeof(tmpPath), path, PLATFORM_TMP_EXT) ? basTextSet(&sText, tmpPath) : "";
}
@@ -835,7 +797,7 @@ int32_t FileOpsUserInTemp(void) {
const char *FileOpsUserPath(const char *rel) {
char path[DVX_MAX_PATH];
- return platformUserPath(path, sizeof(path), rel ? rel : "") ? textOf(path) : "";
+ return platformUserPath(path, sizeof(path), rel ? rel : "") ? basTextSet(&sText, path) : "";
}
@@ -863,60 +825,41 @@ int32_t FileOpsValidShortName(const char *name) {
}
-// A free slot (or a new one) for a listing or a job of the current
-// program. The shell's cleanup hook is registered with the first one.
+// A listing's or job's handle (the handles' freeFn).
+static void handleFree(void *obj) {
+ BasHandleT *hd = (BasHandleT *)obj;
+
+ fileOpsListFree(hd->entries, hd->count);
+ fileOpsJobFree(hd->job);
+ free(hd);
+}
+
+
+// A handle for a listing or a job, owned by the current program:
+// BAS_NO_HANDLE when out of memory (entries and job are the caller's then).
static int32_t handleNew(FileOpsEntryT *entries, int32_t count, FileOpsJobT *job) {
- if (!sCleanupHooked) {
- BasCleanupRegFnT reg = (BasCleanupRegFnT)dlsym(NULL, DVX_SYM(SHELL_CLEANUP_SYM));
+ BasHandleT *hd = (BasHandleT *)calloc(1, sizeof(BasHandleT));
+ int32_t handle;
- sCleanupHooked = true;
-
- if (reg) {
- reg(appEnded);
- }
+ if (!hd) {
+ return BAS_NO_HANDLE;
}
- int32_t idx = 0;
-
- while (idx < (int32_t)arrlen(sHandles) && sHandles[idx].used) {
- idx++;
- }
-
- if (idx == (int32_t)arrlen(sHandles)) {
- BasHandleT blank;
-
- memset(&blank, 0, sizeof(blank));
- arrput(sHandles, blank);
-
- if (idx == (int32_t)arrlen(sHandles)) {
- return BAS_NO_HANDLE;
- }
- }
-
- BasHandleT *hd = &sHandles[idx];
-
hd->entries = entries;
hd->count = count;
hd->job = job;
- hd->appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
- hd->used = true;
- return idx + 1;
+ handle = basHandleAdd(&sHandles, hd);
+
+ if (handle == BAS_NO_HANDLE) {
+ free(hd);
+ }
+
+ return handle;
}
static BasHandleT *handleOf(int32_t handle) {
- if (handle <= BAS_NO_HANDLE || handle > (int32_t)arrlen(sHandles) || !sHandles[handle - 1].used) {
- return NULL;
- }
-
- return &sHandles[handle - 1];
-}
-
-
-static void handleRelease(BasHandleT *hd) {
- fileOpsListFree(hd->entries, hd->count);
- fileOpsJobFree(hd->job);
- memset(hd, 0, sizeof(*hd));
+ return (BasHandleT *)basHandleGet(&sHandles, handle);
}
@@ -980,20 +923,3 @@ static int32_t runTree(FileOpsOpE op, const char *src, const char *destDir, bool
fileOpsJobFree(job);
return ok ? BAS_TRUE : BAS_FALSE;
}
-
-
-// Append s to the string result being built (without its NUL).
-static void textAppend(const char *s) {
- for (const char *p = s; *p; p++) {
- arrput(sText, *p);
- }
-}
-
-
-// s as the string result (a copy that outlives s).
-static const char *textOf(const char *s) {
- arrsetlen(sText, 0);
- textAppend(s ? s : "");
- arrput(sText, '\0');
- return sText;
-}
diff --git a/src/libs/kpunch/game/gameBas.c b/src/libs/kpunch/game/gameBas.c
index a8a631fb..585a233d 100644
--- a/src/libs/kpunch/game/gameBas.c
+++ b/src/libs/kpunch/game/gameBas.c
@@ -29,9 +29,7 @@
// runtime hands over with gameBasSetContext when it starts a program.
#include "game.h"
-
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
+#include "dvxBas.h"
static AppContextT *sBasCtx = NULL;
diff --git a/src/libs/kpunch/hlpfile/hlpFileBas.c b/src/libs/kpunch/hlpfile/hlpFileBas.c
index 8897f3ea..56eab8c3 100644
--- a/src/libs/kpunch/hlpfile/hlpFileBas.c
+++ b/src/libs/kpunch/hlpfile/hlpFileBas.c
@@ -39,6 +39,7 @@
#include "dvxMem.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
@@ -46,9 +47,6 @@
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define BAS_NONE (-1)
#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
diff --git a/src/libs/kpunch/http/Makefile b/src/libs/kpunch/http/Makefile
new file mode 100644
index 00000000..9b24e22e
--- /dev/null
+++ b/src/libs/kpunch/http/Makefile
@@ -0,0 +1,63 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+# http.lib: an HTTP/1.1 client over the network library's sockets
+# (http.c), with its BASIC binding (httpBas.c). dvxnet's include folder
+# comes first: its socket headers (and errno.h) stand in for the C
+# library's, as in a program that uses the network.
+
+include ../../../common.mk
+
+CFLAGS = $(DOS_CFLAGS) -I. -I../dvxnet/include -I../libdvx -I../libdvx/platform -I../libdvx/thirdparty
+OBJDIR = ../../../../obj/http
+LIBSDIR = ../../../../bin/libs
+
+SRCS = http.c httpBas.c
+OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
+
+TARGETDIR = $(LIBSDIR)/kpunch/http
+TARGET = $(TARGETDIR)/http.lib
+
+.PHONY: all clean
+
+all: $(TARGET) $(TARGETDIR)/http.dep
+
+$(TARGETDIR)/http.dep: http.dep | $(TARGETDIR)
+ sed 's/$$/\r/' $< > $@
+
+$(TARGET): $(OBJS) | $(TARGETDIR)
+ $(DXE3GEN) -o $(TARGETDIR)/http.dxe -U $(OBJS)
+ mv $(TARGETDIR)/http.dxe $@
+
+$(OBJDIR)/%.o: %.c $(DVX_MAKEFILES) | $(OBJDIR)
+ $(CC) $(CFLAGS) -c -o $@ $<
+
+$(OBJDIR):
+ mkdir -p $(OBJDIR)
+
+$(TARGETDIR):
+ mkdir -p $(TARGETDIR)
+
+-include $(OBJS:.o=.d)
+
+clean:
+ rm -rf $(OBJS) $(OBJS:.o=.d) $(TARGET) $(TARGETDIR)/http.dep $(TARGETDIR)
diff --git a/src/libs/kpunch/http/http.c b/src/libs/kpunch/http/http.c
new file mode 100644
index 00000000..7a0caba4
--- /dev/null
+++ b/src/libs/kpunch/http/http.c
@@ -0,0 +1,1061 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// http.c -- an HTTP/1.1 client for DVX (see http.h)
+//
+// One request is a small state machine over a non-blocking socket:
+// connect, send the request, read the reply's head (status line and
+// headers), then its body by Content-Length, by chunks, or until the
+// server closes. Each httpPoll does one bounded piece (at most
+// READ_STEP bytes read). Every request says "Connection: close", so a
+// redirect starts a new connection. A body goes to memory, or to the
+// file asked for when the status is 2xx (anything else, an error page,
+// goes to memory and leaves the file alone).
+
+#include "http.h"
+#include "dvxTypes.h"
+#include "dvxPlat.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Sending to a closed connection must give an error, not end the program
+#ifndef MSG_NOSIGNAL
+#define MSG_NOSIGNAL 0
+#endif
+
+#define HTTP_SCHEME "http://"
+#define HTTPS_SCHEME "https://"
+#define SCHEME_SEP "//" // a Location naming another host with the same scheme
+#define HTTP_VERSION_PREFIX "HTTP/1."
+#define USER_AGENT "DVX/" DVX_VERSION_TEXT
+#define CRLF "\r\n"
+#define HEAD_END "\r\n\r\n"
+#define HEAD_MAX 8192 // the reply's status line and headers
+#define READ_STEP 4096 // bytes read a poll
+#define CHUNK_LINE_MAX 64 // a chunk's size line
+#define METHOD_MAX 16
+#define HEADER_VALUE_MAX 512 // the longest reply header value httpHeader gives
+#define NUMBER_TEXT_MAX 24
+#define PORT_MAX 65535
+#define DECIMAL_BASE 10
+#define HEX_BASE 16
+#define MS_PER_SECOND 1000
+#define NO_SOCKET (-1)
+#define STATUS_MIN 100
+#define STATUS_MAX 599
+#define STATUS_INFO_MAX 199 // 1xx: no body
+#define STATUS_OK_MIN 200 // 2xx: the body goes to the file asked for
+#define STATUS_OK_MAX 299
+#define STATUS_NO_CONTENT 204
+#define STATUS_PARTIAL 206
+#define STATUS_MOVED 301 // the redirects
+#define STATUS_FOUND 302
+#define STATUS_SEE_OTHER 303
+#define STATUS_NOT_MODIFIED 304
+#define STATUS_TEMP_REDIRECT 307
+#define STATUS_PERM_REDIRECT 308
+#define METHOD_GET "GET"
+#define METHOD_HEAD "HEAD"
+#define METHOD_POST "POST"
+#define METHOD_PUT "PUT"
+#define FIELD_LENGTH "Content-Length"
+#define FIELD_TYPE "Content-Type"
+#define FIELD_ENCODING "Transfer-Encoding"
+#define FIELD_LOCATION "Location"
+#define ENCODING_CHUNKED "chunked"
+
+typedef enum {
+ StepIdleE,
+ StepConnectE,
+ StepSendE,
+ StepHeadE,
+ StepBodyE,
+ StepDoneE,
+ StepFailedE
+} StepE;
+
+typedef enum {
+ BodyNoneE, // no body (HEAD, 204, 304, 1xx)
+ BodyLengthE, // Content-Length bytes
+ BodyChunkedE, // chunks, then a zero-size one
+ BodyCloseE // until the server closes
+} BodyE;
+
+typedef enum {
+ ChunkSizeE, // reading a chunk's size line
+ ChunkDataE, // reading its bytes
+ ChunkEndE, // the line end after them
+ ChunkTrailerE // the lines after the last chunk, to an empty one
+} ChunkE;
+
+struct HttpS {
+ char method[METHOD_MAX];
+ char url[HTTP_URL_MAX]; // the one being asked (after redirects)
+ HttpUrlT target;
+ char **headers; // stb_ds of "Name: value" (strdup'd)
+ char *body; // the request's body (malloc'd), or NULL
+ int32_t bodyLen;
+ char savePath[DVX_MAX_PATH]; // "": the body to memory
+ bool resume;
+ uint32_t timeoutMs;
+ StepE step;
+ int fd;
+ uint32_t lastMs; // when something last happened
+ char *out; // stb_ds: the request as sent
+ int32_t sent;
+ char *head; // stb_ds: the reply's head, NUL-terminated once read
+ int32_t status;
+ BodyE bodyMode;
+ uint32_t remaining; // BodyLengthE: bytes still to come
+ ChunkE chunk;
+ uint32_t chunkLeft;
+ char chunkLine[CHUNK_LINE_MAX];
+ int32_t chunkLineLen;
+ char *mem; // stb_ds: the body in memory
+ FILE *file; // the body's file while it is written
+ uint32_t resumeFrom; // bytes of the file already there
+ uint32_t done;
+ uint32_t total;
+ int32_t redirects;
+ char headerValue[HEADER_VALUE_MAX];
+ char err[HTTP_ERR_MAX];
+};
+
+DVX_ABI_STAMP(http);
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static bool bodyFeed(HttpT *h, const char *data, int32_t len);
+static bool bodyStart(HttpT *h);
+static bool bodyWrite(HttpT *h, const char *data, int32_t len);
+static bool buildRequest(HttpT *h);
+static bool chunkFeed(HttpT *h, const char *data, int32_t len, int32_t *used);
+static void closeSocket(HttpT *h);
+static bool connectStart(HttpT *h);
+static const char *errnoText(int32_t err);
+static HttpStateE fail(HttpT *h, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+static HttpStateE finish(HttpT *h);
+static const char *headerFind(const char *head, const char *name, char *out, int32_t outSize);
+static bool headParse(HttpT *h, int32_t *headLen);
+static bool hostConnect(HttpT *h);
+const char *httpBody(const HttpT *h, int32_t *len);
+bool httpBodySet(HttpT *h, const void *data, int32_t len, const char *contentType);
+HttpT *httpCreate(const char *method, const char *url);
+void httpFree(HttpT *h);
+const char *httpHeader(const HttpT *h, const char *name);
+bool httpHeaderAdd(HttpT *h, const char *name, const char *value);
+const char *httpMessage(const HttpT *h);
+HttpStateE httpPoll(HttpT *h, uint32_t nowMs);
+void httpProgress(const HttpT *h, uint32_t *done, uint32_t *total);
+void httpSaveTo(HttpT *h, const char *path, bool resume);
+bool httpStart(HttpT *h, uint32_t nowMs);
+int32_t httpStatus(const HttpT *h);
+void httpTimeoutSet(HttpT *h, uint32_t ms);
+const char *httpUrl(const HttpT *h);
+bool httpUrlParse(const char *url, HttpUrlT *out, char *err, int32_t errSize);
+static bool isRedirect(int32_t status);
+static bool locationResolve(const HttpT *h, const char *loc, char *out, int32_t outSize);
+static void replyReset(HttpT *h);
+static bool socketReady(int fd, short events);
+static bool textAdd(char **buf, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+static void textEnd(char **buf);
+static bool wouldBlock(int32_t err);
+
+
+// Bytes of the body as they come, by how it is sent.
+static bool bodyFeed(HttpT *h, const char *data, int32_t len) {
+ if (h->bodyMode == BodyChunkedE) {
+ while (len > 0 && h->step == StepBodyE) {
+ int32_t used = 0;
+
+ if (!chunkFeed(h, data, len, &used)) {
+ return false;
+ }
+
+ data += used;
+ len -= used;
+ }
+
+ return true;
+ }
+
+ if (h->bodyMode == BodyLengthE) {
+ int32_t take = (uint32_t)len < h->remaining ? len : (int32_t)h->remaining;
+
+ if (!bodyWrite(h, data, take)) {
+ return false;
+ }
+
+ h->remaining -= (uint32_t)take;
+
+ if (h->remaining == 0) {
+ h->step = StepDoneE;
+ }
+
+ return true;
+ }
+
+ return bodyWrite(h, data, len);
+}
+
+
+// The head is read: where the body goes, how it comes, and how much of
+// it there is. False (h failed) when the file cannot be opened.
+static bool bodyStart(HttpT *h) {
+ char value[HEADER_VALUE_MAX];
+ bool ok = h->status >= STATUS_OK_MIN && h->status <= STATUS_OK_MAX;
+ bool resume = ok && h->status == STATUS_PARTIAL && h->resumeFrom > 0;
+
+ h->step = StepBodyE;
+
+ if (strcmp(h->method, METHOD_HEAD) == 0 || h->status <= STATUS_INFO_MAX || h->status == STATUS_NO_CONTENT || h->status == STATUS_NOT_MODIFIED) {
+ h->bodyMode = BodyNoneE;
+ } else if (headerFind(h->head, FIELD_ENCODING, value, sizeof(value)) && strcasecmp(value, ENCODING_CHUNKED) == 0) {
+ h->bodyMode = BodyChunkedE;
+ } else if (headerFind(h->head, FIELD_LENGTH, value, sizeof(value))) {
+ h->bodyMode = BodyLengthE;
+ h->remaining = (uint32_t)strtoul(value, NULL, DECIMAL_BASE);
+ h->total = h->remaining + (resume ? h->resumeFrom : 0);
+ } else {
+ h->bodyMode = BodyCloseE;
+ }
+
+ // A resumed file keeps its first part; a 2xx starts it again; any
+ // other status leaves it alone
+ if (h->savePath[0] && ok) {
+ h->file = fopen(h->savePath, resume ? "ab" : "wb");
+ h->done = resume ? h->resumeFrom : 0;
+
+ if (!h->file) {
+ fail(h, "Cannot write %s.", h->savePath);
+ return false;
+ }
+ }
+
+ if (h->bodyMode == BodyNoneE || (h->bodyMode == BodyLengthE && h->remaining == 0)) {
+ h->step = StepDoneE;
+ }
+
+ return true;
+}
+
+
+// Bytes of the body to its file or to memory.
+static bool bodyWrite(HttpT *h, const char *data, int32_t len) {
+ if (len <= 0) {
+ return true;
+ }
+
+ if (h->file) {
+ if (fwrite(data, 1, (size_t)len, h->file) != (size_t)len) {
+ fail(h, "Cannot write %s.", h->savePath);
+ return false;
+ }
+ } else if ((uint32_t)arrlen(h->mem) + (uint32_t)len > HTTP_MEMORY_MAX) {
+ fail(h, "The reply is too big to keep in memory: save it to a file.");
+ return false;
+ } else {
+ memcpy(arraddnptr(h->mem, len), data, (size_t)len);
+ }
+
+ h->done += (uint32_t)len;
+ return true;
+}
+
+
+// The request's text: the request line, the headers, then the body.
+static bool buildRequest(HttpT *h) {
+ bool ok;
+
+ arrfree(h->out);
+ h->sent = 0;
+ ok = textAdd(&h->out, "%s %s HTTP/1.1" CRLF, h->method, h->target.path);
+
+ if (h->target.port == HTTP_PORT_DEFAULT) {
+ ok = ok && textAdd(&h->out, "Host: %s" CRLF, h->target.host);
+ } else {
+ ok = ok && textAdd(&h->out, "Host: %s:%u" CRLF, h->target.host, (unsigned)h->target.port);
+ }
+
+ ok = ok && textAdd(&h->out, "User-Agent: " USER_AGENT CRLF "Accept-Encoding: identity" CRLF "Connection: close" CRLF);
+
+ if (h->resumeFrom > 0) {
+ ok = ok && textAdd(&h->out, "Range: bytes=%lu-" CRLF, (unsigned long)h->resumeFrom);
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(h->headers); i++) {
+ ok = ok && textAdd(&h->out, "%s" CRLF, h->headers[i]);
+ }
+
+ if (h->body || strcmp(h->method, METHOD_POST) == 0 || strcmp(h->method, METHOD_PUT) == 0) {
+ ok = ok && textAdd(&h->out, FIELD_LENGTH ": %ld" CRLF, (long)h->bodyLen);
+ }
+
+ ok = ok && textAdd(&h->out, CRLF);
+
+ if (ok && h->bodyLen > 0) {
+ memcpy(arraddnptr(h->out, h->bodyLen), h->body, (size_t)h->bodyLen);
+ }
+
+ return ok;
+}
+
+
+// Bytes of a chunked body: *used is how many it took.
+static bool chunkFeed(HttpT *h, const char *data, int32_t len, int32_t *used) {
+ *used = 0;
+
+ if (h->chunk == ChunkDataE) {
+ int32_t take = (uint32_t)len < h->chunkLeft ? len : (int32_t)h->chunkLeft;
+
+ *used = take;
+ h->chunkLeft -= (uint32_t)take;
+
+ if (h->chunkLeft == 0) {
+ h->chunk = ChunkEndE;
+ h->chunkLineLen = 0;
+ }
+
+ return bodyWrite(h, data, take);
+ }
+
+ // A line: the size, the end of a chunk's data, or a trailer
+ while (*used < len) {
+ char c = data[(*used)++];
+
+ if (c != '\n') {
+ if (h->chunkLineLen >= CHUNK_LINE_MAX - 1 && h->chunk != ChunkTrailerE) {
+ fail(h, "The server sent a chunk of the reply in a form HTTP does not have.");
+ return false;
+ }
+
+ if (h->chunkLineLen < CHUNK_LINE_MAX - 1) {
+ h->chunkLine[h->chunkLineLen++] = c;
+ }
+
+ continue;
+ }
+
+ if (h->chunkLineLen > 0 && h->chunkLine[h->chunkLineLen - 1] == '\r') {
+ h->chunkLineLen--;
+ }
+
+ h->chunkLine[h->chunkLineLen] = '\0';
+
+ if (h->chunk == ChunkEndE) {
+ h->chunk = ChunkSizeE;
+ } else if (h->chunk == ChunkTrailerE) {
+ if (h->chunkLineLen == 0) {
+ h->step = StepDoneE;
+ }
+ } else {
+ char *end = NULL;
+ long size = strtol(h->chunkLine, &end, HEX_BASE);
+
+ if (end == h->chunkLine || size < 0) {
+ fail(h, "The server sent a chunk of the reply in a form HTTP does not have.");
+ return false;
+ }
+
+ h->chunkLeft = (uint32_t)size;
+ h->chunk = size == 0 ? ChunkTrailerE : ChunkDataE;
+ }
+
+ h->chunkLineLen = 0;
+ return true;
+ }
+
+ return true;
+}
+
+
+static void closeSocket(HttpT *h) {
+ if (h->fd != NO_SOCKET) {
+ close(h->fd);
+ h->fd = NO_SOCKET;
+ }
+}
+
+
+// Look the host up and start connecting (StepConnectE then waits for the
+// connection). False when h failed.
+static bool connectStart(HttpT *h) {
+ replyReset(h);
+
+ if (!buildRequest(h)) {
+ fail(h, "There is not enough memory.");
+ return false;
+ }
+
+ return hostConnect(h);
+}
+
+
+// Plain words for the network errors (the C library's own for others).
+static const char *errnoText(int32_t err) {
+ switch (err) {
+ case ECONNREFUSED:
+ return "the server refused the connection";
+
+ case ETIMEDOUT:
+ return "the connection timed out";
+
+ case ENETUNREACH:
+ case EHOSTUNREACH:
+ return "the network cannot reach it";
+
+ case ECONNRESET:
+ return "the server broke off the connection";
+
+ default:
+ return strerror(err);
+ }
+}
+
+
+static HttpStateE fail(HttpT *h, const char *fmt, ...) {
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(h->err, sizeof(h->err), fmt, ap);
+ va_end(ap);
+ h->step = StepFailedE;
+ closeSocket(h);
+
+ if (h->file) {
+ fclose(h->file);
+ h->file = NULL;
+ }
+
+ return HttpFailedE;
+}
+
+
+// The reply is all in: the connection closed, the file shut, the body in
+// memory ended with a NUL.
+static HttpStateE finish(HttpT *h) {
+ closeSocket(h);
+ h->step = StepDoneE;
+
+ if (h->file) {
+ bool ok = fclose(h->file) == 0;
+
+ h->file = NULL;
+
+ if (!ok) {
+ return fail(h, "Cannot write %s.", h->savePath);
+ }
+ }
+
+ textEnd(&h->mem);
+ return HttpDoneE;
+}
+
+
+// The value of header name in a reply's head (its status line first), in
+// out, spaces around it trimmed; NULL when there is none.
+static const char *headerFind(const char *head, const char *name, char *out, int32_t outSize) {
+ size_t nameLen = strlen(name);
+ const char *line = head ? strstr(head, CRLF) : NULL;
+
+ while (line) {
+ line += strlen(CRLF);
+
+ const char *end = strstr(line, CRLF);
+
+ if (!end) {
+ end = line + strlen(line);
+ }
+
+ if ((size_t)(end - line) > nameLen && strncasecmp(line, name, nameLen) == 0 && line[nameLen] == ':') {
+ const char *v = line + nameLen + 1;
+
+ while (v < end && (*v == ' ' || *v == '\t')) {
+ v++;
+ }
+
+ const char *e = end;
+
+ while (e > v && (e[-1] == ' ' || e[-1] == '\t')) {
+ e--;
+ }
+
+ snprintf(out, (size_t)outSize, "%.*s", (int)(e - v), v);
+ return out;
+ }
+
+ line = *end ? end : NULL;
+ }
+
+ return NULL;
+}
+
+
+// Look for the end of the head in what has come: *headLen its length
+// (the blank line included) once found, else 0. False (h failed) for a
+// reply that is not HTTP or a head too long.
+static bool headParse(HttpT *h, int32_t *headLen) {
+ char *end;
+
+ textEnd(&h->head);
+ end = strstr(h->head, HEAD_END);
+ *headLen = 0;
+
+ if (!end) {
+ if (arrlen(h->head) > HEAD_MAX) {
+ fail(h, "The server's reply has a head too long to read.");
+ return false;
+ }
+
+ return true;
+ }
+
+ *headLen = (int32_t)(end - h->head) + (int32_t)strlen(HEAD_END);
+
+ const char *p = h->head + strlen(HTTP_VERSION_PREFIX);
+ long status = 0;
+
+ if (strncmp(h->head, HTTP_VERSION_PREFIX, strlen(HTTP_VERSION_PREFIX)) == 0 && *p) {
+ p++;
+
+ while (*p == ' ') {
+ p++;
+ }
+
+ status = strtol(p, NULL, DECIMAL_BASE);
+ }
+
+ if (status < STATUS_MIN || status > STATUS_MAX) {
+ fail(h, "%s did not answer in HTTP.", h->target.host);
+ return false;
+ }
+
+ h->status = (int32_t)status;
+ return true;
+}
+
+
+// The host's address, a socket, and a connection started.
+static bool hostConnect(HttpT *h) {
+ struct addrinfo hints;
+ struct addrinfo *found = NULL;
+ char port[NUMBER_TEXT_MAX];
+ unsigned long on = 1;
+ int rc;
+
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_STREAM;
+ snprintf(port, sizeof(port), "%u", (unsigned)h->target.port);
+
+ if (getaddrinfo(h->target.host, port, &hints, &found) != 0 || !found) {
+ fail(h, "Cannot find %s on the network.", h->target.host);
+ return false;
+ }
+
+ h->fd = socket(AF_INET, SOCK_STREAM, 0);
+
+ if (h->fd < 0 || ioctl(h->fd, FIONBIO, &on) != 0) {
+ int32_t err = errno;
+
+ freeaddrinfo(found);
+ h->fd = NO_SOCKET;
+ fail(h, "Cannot connect to %s: %s.", h->target.host, errnoText(err));
+ return false;
+ }
+
+ rc = connect(h->fd, found->ai_addr, found->ai_addrlen);
+ freeaddrinfo(found);
+
+ if (rc != 0 && errno != EINPROGRESS && !wouldBlock(errno)) {
+ fail(h, "Cannot connect to %s: %s.", h->target.host, errnoText(errno));
+ return false;
+ }
+
+ h->step = StepConnectE;
+ return true;
+}
+
+
+const char *httpBody(const HttpT *h, int32_t *len) {
+ if (len) {
+ *len = h ? (int32_t)arrlen(h->mem) : 0;
+ }
+
+ return (h && h->mem && h->step == StepDoneE) ? h->mem : "";
+}
+
+
+bool httpBodySet(HttpT *h, const void *data, int32_t len, const char *contentType) {
+ char *copy = (char *)malloc(len > 0 ? (size_t)len : 1);
+
+ if (!copy) {
+ return false;
+ }
+
+ memcpy(copy, data, len > 0 ? (size_t)len : 0);
+ free(h->body);
+ h->body = copy;
+ h->bodyLen = len > 0 ? len : 0;
+ return !contentType || !contentType[0] || httpHeaderAdd(h, FIELD_TYPE, contentType);
+}
+
+
+HttpT *httpCreate(const char *method, const char *url) {
+ HttpT *h = (HttpT *)calloc(1, sizeof(HttpT));
+
+ if (!h) {
+ return NULL;
+ }
+
+ snprintf(h->method, sizeof(h->method), "%s", method && method[0] ? method : METHOD_GET);
+ snprintf(h->url, sizeof(h->url), "%s", url ? url : "");
+ h->fd = NO_SOCKET;
+ h->timeoutMs = HTTP_TIMEOUT_DEFAULT_MS;
+ h->total = HTTP_SIZE_UNKNOWN;
+ return h;
+}
+
+
+void httpFree(HttpT *h) {
+ if (!h) {
+ return;
+ }
+
+ closeSocket(h);
+
+ if (h->file) {
+ fclose(h->file);
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(h->headers); i++) {
+ free(h->headers[i]);
+ }
+
+ arrfree(h->headers);
+ free(h->body);
+ arrfree(h->out);
+ arrfree(h->head);
+ arrfree(h->mem);
+ free(h);
+}
+
+
+const char *httpHeader(const HttpT *h, const char *name) {
+ HttpT *mut = (HttpT *)h;
+
+ if (!h || !name || !headerFind(h->head, name, mut->headerValue, sizeof(mut->headerValue))) {
+ return "";
+ }
+
+ return h->headerValue;
+}
+
+
+bool httpHeaderAdd(HttpT *h, const char *name, const char *value) {
+ char *line = NULL;
+
+ if (!textAdd(&line, "%s: %s", name, value)) {
+ arrfree(line);
+ return false;
+ }
+
+ char *copy = strdup(line);
+
+ arrfree(line);
+
+ if (!copy) {
+ return false;
+ }
+
+ arrput(h->headers, copy);
+ return true;
+}
+
+
+const char *httpMessage(const HttpT *h) {
+ return (h && h->step == StepFailedE) ? h->err : "";
+}
+
+
+HttpStateE httpPoll(HttpT *h, uint32_t nowMs) {
+ char buf[READ_STEP];
+
+ if (!h || h->step == StepIdleE || h->step == StepFailedE) {
+ return HttpFailedE;
+ }
+
+ if (h->step == StepDoneE) {
+ return HttpDoneE;
+ }
+
+ if (h->step == StepConnectE && h->fd == NO_SOCKET) {
+ if (!connectStart(h)) {
+ return HttpFailedE;
+ }
+
+ h->lastMs = nowMs;
+ }
+
+ if (h->step == StepConnectE) {
+ if (socketReady(h->fd, POLLOUT)) {
+ int soErr = 0;
+ socklen_t len = sizeof(soErr);
+
+ if (getsockopt(h->fd, SOL_SOCKET, SO_ERROR, &soErr, &len) != 0 || soErr != 0) {
+ return fail(h, "Cannot connect to %s: %s.", h->target.host, errnoText(soErr ? soErr : errno));
+ }
+
+ h->step = StepSendE;
+ h->lastMs = nowMs;
+ }
+ }
+
+ if (h->step == StepSendE && socketReady(h->fd, POLLOUT)) {
+ ssize_t n = send(h->fd, h->out + h->sent, (size_t)(arrlen(h->out) - h->sent), MSG_NOSIGNAL);
+
+ if (n < 0 && !wouldBlock(errno)) {
+ return fail(h, "Cannot send to %s: %s.", h->target.host, errnoText(errno));
+ }
+
+ if (n > 0) {
+ h->sent += (int32_t)n;
+ h->lastMs = nowMs;
+ }
+
+ if (h->sent == (int32_t)arrlen(h->out)) {
+ h->step = StepHeadE;
+ }
+ }
+
+ if ((h->step == StepHeadE || h->step == StepBodyE) && socketReady(h->fd, POLLIN)) {
+ ssize_t n = recv(h->fd, buf, sizeof(buf), 0);
+
+ if (n < 0 && !wouldBlock(errno)) {
+ return fail(h, "Lost the connection to %s: %s.", h->target.host, errnoText(errno));
+ }
+
+ if (n == 0 && h->step == StepHeadE) {
+ return fail(h, "%s closed the connection without answering.", h->target.host);
+ }
+
+ if (n == 0) {
+ if (h->bodyMode != BodyCloseE) {
+ return fail(h, "%s closed the connection before the whole reply came.", h->target.host);
+ }
+
+ h->step = StepDoneE;
+ }
+
+ if (n > 0) {
+ h->lastMs = nowMs;
+
+ if (h->step == StepHeadE) {
+ int32_t headLen = 0;
+
+ memcpy(arraddnptr(h->head, n), buf, (size_t)n);
+
+ if (!headParse(h, &headLen)) {
+ return HttpFailedE;
+ }
+
+ if (headLen > 0) {
+ char loc[HEADER_VALUE_MAX];
+ char next[HTTP_URL_MAX];
+ int32_t rest = (int32_t)arrlen(h->head) - headLen;
+
+ // The body's first bytes, then the head alone (NUL-ended):
+ // they came in this read, so they fit in buf
+ if (rest > 0 && rest <= (int32_t)sizeof(buf)) {
+ memcpy(buf, h->head + headLen, (size_t)rest);
+ }
+
+ arrsetlen(h->head, headLen);
+ textEnd(&h->head);
+
+ // A redirect: the same request (a GET after 303, or
+ // after 301 and 302 of a POST) to where it points
+ if (isRedirect(h->status) && headerFind(h->head, FIELD_LOCATION, loc, sizeof(loc))) {
+ if (h->redirects >= HTTP_REDIRECTS_MAX) {
+ return fail(h, "%s sent the request elsewhere too many times.", h->target.host);
+ }
+
+ if (!locationResolve(h, loc, next, sizeof(next)) || !httpUrlParse(next, &h->target, h->err, sizeof(h->err))) {
+ return fail(h, "%s sent the request to %s, which is not an http:// address.", h->target.host, loc);
+ }
+
+ h->redirects++;
+ snprintf(h->url, sizeof(h->url), "%s", next);
+
+ if (h->status == STATUS_SEE_OTHER || ((h->status == STATUS_MOVED || h->status == STATUS_FOUND) && strcmp(h->method, METHOD_POST) == 0)) {
+ snprintf(h->method, sizeof(h->method), "%s", METHOD_GET);
+ free(h->body);
+ h->body = NULL;
+ h->bodyLen = 0;
+ }
+
+ closeSocket(h);
+ h->step = StepConnectE;
+ h->lastMs = nowMs;
+ return HttpBusyE;
+ }
+
+ if (!bodyStart(h)) {
+ return HttpFailedE;
+ }
+
+ if (h->step == StepBodyE && rest > 0 && !bodyFeed(h, buf, rest)) {
+ return HttpFailedE;
+ }
+ }
+ } else if (!bodyFeed(h, buf, (int32_t)n)) {
+ return HttpFailedE;
+ }
+ }
+ }
+
+ if (h->step == StepDoneE) {
+ return finish(h);
+ }
+
+ if ((uint32_t)(nowMs - h->lastMs) > h->timeoutMs) {
+ return fail(h, "%s did not answer in time (%lu seconds).", h->target.host, (unsigned long)(h->timeoutMs / MS_PER_SECOND));
+ }
+
+ return HttpBusyE;
+}
+
+
+void httpProgress(const HttpT *h, uint32_t *done, uint32_t *total) {
+ *done = h ? h->done : 0;
+ *total = h ? h->total : HTTP_SIZE_UNKNOWN;
+}
+
+
+void httpSaveTo(HttpT *h, const char *path, bool resume) {
+ snprintf(h->savePath, sizeof(h->savePath), "%s", path ? path : "");
+ h->resume = resume && h->savePath[0];
+}
+
+
+bool httpStart(HttpT *h, uint32_t nowMs) {
+ struct stat st;
+
+ if (!h || h->step != StepIdleE) {
+ return false;
+ }
+
+ if (!httpUrlParse(h->url, &h->target, h->err, sizeof(h->err))) {
+ h->step = StepFailedE;
+ return false;
+ }
+
+ h->resumeFrom = (h->resume && stat(h->savePath, &st) == 0 && st.st_size > 0) ? (uint32_t)st.st_size : 0;
+ h->step = StepConnectE;
+ h->lastMs = nowMs;
+ return true;
+}
+
+
+int32_t httpStatus(const HttpT *h) {
+ return h ? h->status : 0;
+}
+
+
+void httpTimeoutSet(HttpT *h, uint32_t ms) {
+ h->timeoutMs = ms;
+}
+
+
+const char *httpUrl(const HttpT *h) {
+ return h ? h->url : "";
+}
+
+
+bool httpUrlParse(const char *url, HttpUrlT *out, char *err, int32_t errSize) {
+ const char *p = url ? url : "";
+ size_t hostLen;
+
+ memset(out, 0, sizeof(*out));
+
+ if (strncasecmp(p, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0) {
+ snprintf(err, (size_t)errSize, "%s needs encryption, which DVX does not have: use an http:// address.", p);
+ return false;
+ }
+
+ if (strncasecmp(p, HTTP_SCHEME, strlen(HTTP_SCHEME)) != 0) {
+ snprintf(err, (size_t)errSize, "%s is not an http:// address.", p);
+ return false;
+ }
+
+ p += strlen(HTTP_SCHEME);
+ hostLen = strcspn(p, ":/?#@");
+
+ if (hostLen == 0 || hostLen >= sizeof(out->host) || p[hostLen] == '@') {
+ snprintf(err, (size_t)errSize, "%s does not name a computer.", url);
+ return false;
+ }
+
+ memcpy(out->host, p, hostLen);
+ out->port = HTTP_PORT_DEFAULT;
+ p += hostLen;
+
+ if (*p == ':') {
+ char *end = NULL;
+ long port = strtol(p + 1, &end, DECIMAL_BASE);
+
+ if (end == p + 1 || port < 1 || port > PORT_MAX || (*end && *end != '/' && *end != '?' && *end != '#')) {
+ snprintf(err, (size_t)errSize, "%s has a port that is not a number from 1 to %d.", url, PORT_MAX);
+ return false;
+ }
+
+ out->port = (uint16_t)port;
+ p = end;
+ }
+
+ // The path and query, without a fragment
+ size_t pathLen = strcspn(p, "#");
+ int n = snprintf(out->path, sizeof(out->path), "%s%.*s", *p == '/' ? "" : "/", (int)pathLen, p);
+
+ if (n < 0 || n >= (int)sizeof(out->path)) {
+ snprintf(err, (size_t)errSize, "The address is too long.");
+ return false;
+ }
+
+ return true;
+}
+
+
+static bool isRedirect(int32_t status) {
+ return status == STATUS_MOVED || status == STATUS_FOUND || status == STATUS_SEE_OTHER || status == STATUS_TEMP_REDIRECT || status == STATUS_PERM_REDIRECT;
+}
+
+
+// Where a redirect's Location points, as a whole URL: as it is when it is
+// one, on the same computer when it starts with /, on another with //,
+// else beside the path asked for.
+static bool locationResolve(const HttpT *h, const char *loc, char *out, int32_t outSize) {
+ char here[HTTP_HOST_MAX + NUMBER_TEXT_MAX];
+ const char *slash;
+ int n;
+
+ if (strncasecmp(loc, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0 || strncasecmp(loc, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0) {
+ n = snprintf(out, (size_t)outSize, "%s", loc);
+ } else if (strncmp(loc, SCHEME_SEP, strlen(SCHEME_SEP)) == 0) {
+ n = snprintf(out, (size_t)outSize, "http:%s", loc);
+ } else {
+ if (h->target.port == HTTP_PORT_DEFAULT) {
+ snprintf(here, sizeof(here), HTTP_SCHEME "%s", h->target.host);
+ } else {
+ snprintf(here, sizeof(here), HTTP_SCHEME "%s:%u", h->target.host, (unsigned)h->target.port);
+ }
+
+ if (loc[0] == '/') {
+ n = snprintf(out, (size_t)outSize, "%s%s", here, loc);
+ } else {
+ slash = strrchr(h->target.path, '/');
+ n = snprintf(out, (size_t)outSize, "%s%.*s%s", here, (int)(slash - h->target.path + 1), h->target.path, loc);
+ }
+ }
+
+ return n > 0 && n < outSize;
+}
+
+
+// Forget the reply so far (a new connection: the first, or a redirect's).
+static void replyReset(HttpT *h) {
+ arrfree(h->head);
+ arrfree(h->mem);
+ h->status = 0;
+ h->bodyMode = BodyNoneE;
+ h->remaining = 0;
+ h->chunk = ChunkSizeE;
+ h->chunkLeft = 0;
+ h->chunkLineLen = 0;
+ h->done = 0;
+ h->total = HTTP_SIZE_UNKNOWN;
+}
+
+
+// True when fd can do events now (or has failed: the call says how). On
+// DOS this also lets the network stack do its work.
+static bool socketReady(int fd, short events) {
+ struct pollfd pfd;
+
+ pfd.fd = fd;
+ pfd.events = events;
+ pfd.revents = 0;
+ return poll(&pfd, 1, 0) > 0 && (pfd.revents & (events | POLLERR | POLLHUP)) != 0;
+}
+
+
+// Add printf text to an stb_ds char array (no NUL kept); false when it
+// cannot be formatted or memory runs out.
+static bool textAdd(char **buf, const char *fmt, ...) {
+ va_list ap;
+ int n;
+
+ va_start(ap, fmt);
+ n = vsnprintf(NULL, 0, fmt, ap);
+ va_end(ap);
+
+ if (n < 0) {
+ return false;
+ }
+
+ char *at = arraddnptr(*buf, n + 1);
+
+ va_start(ap, fmt);
+ vsnprintf(at, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ arrsetlen(*buf, arrlen(*buf) - 1);
+ return true;
+}
+
+
+// A NUL after the bytes of an stb_ds char array, not counted in its
+// length, so they read as a C string.
+static void textEnd(char **buf) {
+ arrput(*buf, '\0');
+ arrsetlen(*buf, arrlen(*buf) - 1);
+}
+
+
+// True when err says only that nothing is ready yet.
+static bool wouldBlock(int32_t err) {
+ return err == EAGAIN || err == EWOULDBLOCK;
+}
diff --git a/src/libs/kpunch/http/http.dep b/src/libs/kpunch/http/http.dep
new file mode 100644
index 00000000..9e2c75a2
--- /dev/null
+++ b/src/libs/kpunch/http/http.dep
@@ -0,0 +1,2 @@
+dvxnet
+libdvx
diff --git a/src/libs/kpunch/http/http.dhs b/src/libs/kpunch/http/http.dhs
new file mode 100644
index 00000000..10813b5b
--- /dev/null
+++ b/src/libs/kpunch/http/http.dhs
@@ -0,0 +1,85 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+.section Technical Reference (for Developers)|Libraries
+.topic lib.http
+.title HTTP Library
+.toc 0 HTTP Library
+.index http
+.index HTTP
+.index Web requests
+.index httpCreate
+.index httpPoll
+.index httpSaveTo
+.index http.bas
+
+.h1 HTTP Library
+
+An HTTP/1.1 client: GET, POST, and other requests to a web server over the network library's sockets, run a step at a time like the transfer and package jobs, so a program draws its progress and stays responsive. Software talks to the package server with it.
+
+Header: http/http.h
+
+Loaded as: bin/libs/kpunch/http/http.lib (it needs the network library, dvxnet)
+
+BASIC: http.bas (HttpOpen, HttpStart, HttpRunFor, HttpStatus, HttpBody$, ...).
+
+.h2 A Request
+
+.code
+HttpT *h = httpCreate("GET", "http://example.com/v1/catalogue");
+
+if (h && httpStart(h, nowMs)) {
+ while (httpPoll(h, nowMs) == HttpBusyE) {
+ // draw progress (httpProgress), yield to other programs
+ }
+}
+
+// HttpDoneE: httpStatus(h) is 200, 404, ...; httpBody(h, &len) the reply
+// HttpFailedE: httpMessage(h) says why
+httpFree(h);
+.endcode
+
+nowMs is a millisecond clock (PLATFORM_TICKS_TO_MS(platformClock())); it may wrap. Each httpPoll does one bounded piece of work: start connecting, send, or read up to 4 KB; on DOS it also lets the network stack run. Every request says Connection: close.
+
+.table
+ Function Description
+ -------- -----------
+ httpCreate(method, url) A request; NULL when out of memory.
+ httpHeaderAdd(h, name, value) A header to send (before starting).
+ httpBodySet(h, data, len, contentType) A body to send (copied); Content-Length is sent for it, and for any POST or PUT.
+ httpSaveTo(h, path, resume) The body to a file instead of memory; with resume, a part already there is kept and the rest asked for (a Range request).
+ httpTimeoutSet(h, ms) Give up after ms without progress (HTTP_TIMEOUT_DEFAULT_MS, 30 seconds, to start with).
+ httpStart(h, nowMs) False (httpMessage) for an address that is not http://.
+ httpPoll(h, nowMs) HttpBusyE, HttpDoneE, or HttpFailedE.
+ httpProgress(h, &done, &total) Bytes of the body so far (counting a resumed file's first part), and the whole, HTTP_SIZE_UNKNOWN until the server says.
+ httpStatus(h), httpHeader(h, name) The reply's status and its headers (any case).
+ httpBody(h, &len) The body in memory, NUL-terminated ("" when it went to a file).
+ httpUrl(h) Where the reply came from, after redirects.
+ httpUrlParse(url, &parts, err, size) Split an address into host, port, and path.
+.endtable
+
+.h2 What Comes Back
+
+Any status is an answer (HttpDoneE): the program decides what 404 or 500 means. HttpFailedE is for no answer: a bad address, a name that cannot be found, no connection, silence past the timeout, a reply that is not HTTP, or one cut short. Bodies may be sized (Content-Length), chunked, or end when the server closes; they go to memory up to HTTP_MEMORY_MAX (1 MB), past which the request fails -- save big ones to a file. Redirects (301, 302, 303, 307, 308) are followed, HTTP_REDIRECTS_MAX (5) at most; after a 303, or a 301 or 302 of a POST, the request becomes a GET without its body.
+
+Saving to a file: only a 2xx body is written to it. A resumed download that the server answers with 206 is appended to the part there; one it answers with 200 (it will not resume) starts the file again; 416 (asked for past the end: the file was already whole) leaves it as it is. Any other status's body -- an error page -- goes to memory and the file stays as it was, so a download can be resumed later. Check the file afterwards (its size and SHA-256, as the package library does) before using it.
+
+Only plain http:// works: there is no encryption, so send nothing secret, and check what comes back by its signature or hash.
diff --git a/src/libs/kpunch/http/http.h b/src/libs/kpunch/http/http.h
new file mode 100644
index 00000000..9b2c2e94
--- /dev/null
+++ b/src/libs/kpunch/http/http.h
@@ -0,0 +1,130 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// http.h -- an HTTP/1.1 client for DVX (LIBS\KPUNCH\HTTP\HTTP.LIB): a
+// GET, POST, or other request to a web server, over the network
+// library's sockets (dvxNet.h), run a step at a time like the transfer
+// and package jobs, so a program draws its progress and stays responsive.
+//
+// HttpT *h = httpCreate("GET", "http://example.com/v1/catalogue");
+//
+// httpStart(h, nowMs);
+//
+// while (httpPoll(h, nowMs) == HttpBusyE) {
+// // draw progress (httpProgress), let other programs run
+// }
+//
+// // httpStatus(h): 200, 404, ...; httpBody(h, &len): the reply
+// httpFree(h);
+//
+// The reply's body goes to memory (up to HTTP_MEMORY_MAX bytes) or to a
+// file (httpSaveTo), which can resume a file left part-way (a Range
+// request; a server that does not resume sends it whole, and the file
+// starts again). Bodies may be sized (Content-Length), chunked, or end
+// when the server closes. Redirects (301, 302, 303, 307, 308) are
+// followed, HTTP_REDIRECTS_MAX at most. Any status is an answer: a
+// request fails (HttpFailedE, httpMessage says why) only when there is no
+// answer: a bad URL, a name that cannot be found, no connection, silence
+// past the timeout, or a reply that is not HTTP. Plain http:// only (no
+// encryption).
+
+#ifndef HTTP_H
+#define HTTP_H
+
+#include
+#include
+
+#define HTTP_URL_MAX 512
+#define HTTP_HOST_MAX 128
+#define HTTP_ERR_MAX (HTTP_URL_MAX + 64) // longest reason httpMessage gives
+#define HTTP_PORT_DEFAULT 80
+#define HTTP_REDIRECTS_MAX 5
+#define HTTP_TIMEOUT_DEFAULT_MS 30000 // connecting, and between pieces of the reply
+#define HTTP_MEMORY_MAX (1024L * 1024L) // largest body kept in memory
+#define HTTP_SIZE_UNKNOWN UINT32_MAX // httpProgress's total before (or without) a Content-Length
+
+typedef enum {
+ HttpBusyE, // more to do: poll again
+ HttpDoneE, // the server answered (any status)
+ HttpFailedE // no answer (httpMessage says why)
+} HttpStateE;
+
+typedef struct {
+ char host[HTTP_HOST_MAX];
+ uint16_t port;
+ char path[HTTP_URL_MAX]; // path and query, "/" at least
+} HttpUrlT;
+
+typedef struct HttpS HttpT;
+
+// The reply's body in memory (NUL-terminated; *len its length, NUL not
+// counted). "" when it went to a file, or before the reply.
+const char *httpBody(const HttpT *h, int32_t *len);
+
+// Send data (copied) as the request's body, of contentType.
+bool httpBodySet(HttpT *h, const void *data, int32_t len, const char *contentType);
+
+// A request of method ("GET", "POST", ...) to url; NULL when out of memory
+// (a bad url fails when started).
+HttpT *httpCreate(const char *method, const char *url);
+
+void httpFree(HttpT *h);
+
+// A header of the reply, by name in any case ("" when it has none).
+const char *httpHeader(const HttpT *h, const char *name);
+
+// Add a header to the request (before it starts); false when out of
+// memory.
+bool httpHeaderAdd(HttpT *h, const char *name, const char *value);
+
+// Why the request failed ("" when it has not).
+const char *httpMessage(const HttpT *h);
+
+// Do the next piece of the request (connect, send, read what has come):
+// how it stands. nowMs is a millisecond clock (it may wrap).
+HttpStateE httpPoll(HttpT *h, uint32_t nowMs);
+
+// Bytes of the body so far (counting a resumed file's first part), and
+// the whole (HTTP_SIZE_UNKNOWN when the server has not said).
+void httpProgress(const HttpT *h, uint32_t *done, uint32_t *total);
+
+// Write the body to the file at path instead of memory; with resume, a
+// part already there is kept and the rest asked for.
+void httpSaveTo(HttpT *h, const char *path, bool resume);
+
+// Start the request: false (httpMessage says why) for a bad URL.
+bool httpStart(HttpT *h, uint32_t nowMs);
+
+// The reply's status (200, 404, ...; 0 before it).
+int32_t httpStatus(const HttpT *h);
+
+// Give up after ms without progress (connecting, or between pieces of
+// the reply); HTTP_TIMEOUT_DEFAULT_MS to start with.
+void httpTimeoutSet(HttpT *h, uint32_t ms);
+
+// The URL the reply came from (after redirects).
+const char *httpUrl(const HttpT *h);
+
+// Split an http:// URL; false with the reason when it is not one.
+bool httpUrlParse(const char *url, HttpUrlT *out, char *err, int32_t errSize);
+
+#endif // HTTP_H
diff --git a/src/libs/kpunch/http/httpBas.c b/src/libs/kpunch/http/httpBas.c
new file mode 100644
index 00000000..baaee49b
--- /dev/null
+++ b/src/libs/kpunch/http/httpBas.c
@@ -0,0 +1,213 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// httpBas.c -- http.bas: web requests for DVX BASIC programs
+//
+// HttpOpen gives a handle for one request; the program sets it up
+// (headers, a body, a file to save to), starts it, and runs it a little
+// at a time with HttpRunFor (from a loop with DoEvents, or a Timer) until
+// it is HTTP_DONE or HTTP_FAILED. A program's requests are closed for it
+// when it ends.
+
+#include "httpBas.h"
+#include "http.h"
+#include "dvxBas.h"
+#include "dvxPlat.h"
+
+#include
+
+#define MS_PER_SECOND 1000
+#define SIZE_UNKNOWN_BAS (-1.0) // HttpTotal before (or without) a size
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+int32_t HttpAddHeader(int32_t h, const char *name, const char *value);
+const uint8_t *HttpBody(int32_t h, int32_t *outLen);
+double HttpBytes(int32_t h);
+void HttpClose(int32_t h);
+const char *HttpError(int32_t h);
+int32_t HttpOpen(const char *method, const char *url);
+const char *HttpReplyHeader(int32_t h, const char *name);
+int32_t HttpRunFor(int32_t h, int32_t milliseconds);
+void HttpSaveTo(int32_t h, const char *path, int32_t resume);
+int32_t HttpSetBody(int32_t h, const uint8_t *data, int32_t len, const char *contentType);
+void HttpSetTimeout(int32_t h, int32_t seconds);
+int32_t HttpStart(int32_t h);
+int32_t HttpStatus(int32_t h);
+double HttpTotal(int32_t h);
+const char *HttpUrl(int32_t h);
+static uint32_t nowMs(void);
+static void requestFree(void *obj);
+static HttpT *requestOf(int32_t h);
+
+// ============================================================
+// Module state
+// ============================================================
+
+static BasHandlesT sRequests = BAS_HANDLES(requestFree, 0); // the open requests
+static BasTextT sText; // the last string result
+
+
+// Add a header to the request (before it starts).
+int32_t HttpAddHeader(int32_t h, const char *name, const char *value) {
+ HttpT *r = requestOf(h);
+
+ return (r && name && name[0] && httpHeaderAdd(r, name, value ? value : "")) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+// The reply's body ("" when it went to a file, or before the reply).
+const uint8_t *HttpBody(int32_t h, int32_t *outLen) {
+ return (const uint8_t *)httpBody(requestOf(h), outLen);
+}
+
+
+// Bytes of the body so far.
+double HttpBytes(int32_t h) {
+ uint32_t done = 0;
+ uint32_t total = 0;
+
+ httpProgress(requestOf(h), &done, &total);
+ return (double)done;
+}
+
+
+void HttpClose(int32_t h) {
+ basHandleFree(&sRequests, h);
+}
+
+
+// Why the request failed ("" when it has not).
+const char *HttpError(int32_t h) {
+ return basTextSet(&sText, httpMessage(requestOf(h)));
+}
+
+
+// A request of method ("GET", "POST", ...) to url: a handle, or 0 when
+// out of memory.
+int32_t HttpOpen(const char *method, const char *url) {
+ HttpT *r = httpCreate(method, url);
+ int32_t h = basHandleAdd(&sRequests, r);
+
+ if (h == BAS_NO_HANDLE) {
+ httpFree(r);
+ }
+
+ return h;
+}
+
+
+// A header of the reply ("" when it has none).
+const char *HttpReplyHeader(int32_t h, const char *name) {
+ return basTextSet(&sText, httpHeader(requestOf(h), name));
+}
+
+
+// Run the request for up to milliseconds (at least one step): HTTP_BUSY,
+// HTTP_DONE, or HTTP_FAILED.
+int32_t HttpRunFor(int32_t h, int32_t milliseconds) {
+ HttpT *r = requestOf(h);
+ PlatformTicksT end = platformClock() + PLATFORM_MS_TO_TICKS(milliseconds > 0 ? milliseconds : 0);
+ HttpStateE st;
+
+ do {
+ st = httpPoll(r, nowMs());
+ } while (st == HttpBusyE && platformClock() < end);
+
+ return (int32_t)st;
+}
+
+
+// Write the body to the file at path; with resume, keep a part already
+// there and ask for the rest.
+void HttpSaveTo(int32_t h, const char *path, int32_t resume) {
+ HttpT *r = requestOf(h);
+
+ if (r) {
+ httpSaveTo(r, path, resume != BAS_FALSE);
+ }
+}
+
+
+// Send data as the request's body, of contentType.
+int32_t HttpSetBody(int32_t h, const uint8_t *data, int32_t len, const char *contentType) {
+ HttpT *r = requestOf(h);
+
+ return (r && httpBodySet(r, data, len, contentType)) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+// Give up after seconds without progress.
+void HttpSetTimeout(int32_t h, int32_t seconds) {
+ HttpT *r = requestOf(h);
+
+ if (r && seconds > 0) {
+ httpTimeoutSet(r, (uint32_t)seconds * MS_PER_SECOND);
+ }
+}
+
+
+// Start the request: False (HttpError$ says why) for a bad address.
+int32_t HttpStart(int32_t h) {
+ HttpT *r = requestOf(h);
+
+ return (r && httpStart(r, nowMs())) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+int32_t HttpStatus(int32_t h) {
+ return httpStatus(requestOf(h));
+}
+
+
+// The whole body's size; -1 when the server has not said.
+double HttpTotal(int32_t h) {
+ uint32_t done = 0;
+ uint32_t total = 0;
+
+ httpProgress(requestOf(h), &done, &total);
+ return total == HTTP_SIZE_UNKNOWN ? SIZE_UNKNOWN_BAS : (double)total;
+}
+
+
+// The address the reply came from (after redirects).
+const char *HttpUrl(int32_t h) {
+ return basTextSet(&sText, httpUrl(requestOf(h)));
+}
+
+
+static uint32_t nowMs(void) {
+ return PLATFORM_TICKS_TO_MS(platformClock());
+}
+
+
+// A request (the handles' freeFn).
+static void requestFree(void *obj) {
+ httpFree((HttpT *)obj);
+}
+
+
+static HttpT *requestOf(int32_t h) {
+ return (HttpT *)basHandleGet(&sRequests, h);
+}
diff --git a/src/libs/kpunch/http/httpBas.h b/src/libs/kpunch/http/httpBas.h
new file mode 100644
index 00000000..6b9dea59
--- /dev/null
+++ b/src/libs/kpunch/http/httpBas.h
@@ -0,0 +1,48 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// httpBas.h -- the C side of http.bas (httpBas.c): the functions BASIC
+// calls. http.bas's HTTP_BUSY, HTTP_DONE, and HTTP_FAILED are HttpStateE's
+// numbers (http.h).
+
+#ifndef HTTP_BAS_H
+#define HTTP_BAS_H
+
+#include
+
+int32_t HttpAddHeader(int32_t h, const char *name, const char *value);
+const uint8_t *HttpBody(int32_t h, int32_t *outLen);
+double HttpBytes(int32_t h);
+void HttpClose(int32_t h);
+const char *HttpError(int32_t h);
+int32_t HttpOpen(const char *method, const char *url);
+const char *HttpReplyHeader(int32_t h, const char *name);
+int32_t HttpRunFor(int32_t h, int32_t milliseconds);
+void HttpSaveTo(int32_t h, const char *path, int32_t resume);
+int32_t HttpSetBody(int32_t h, const uint8_t *data, int32_t len, const char *contentType);
+void HttpSetTimeout(int32_t h, int32_t seconds);
+int32_t HttpStart(int32_t h);
+int32_t HttpStatus(int32_t h);
+double HttpTotal(int32_t h);
+const char *HttpUrl(int32_t h);
+
+#endif // HTTP_BAS_H
diff --git a/src/libs/kpunch/joystick/joyBas.c b/src/libs/kpunch/joystick/joyBas.c
index 60cad6e0..3ec7a9b7 100644
--- a/src/libs/kpunch/joystick/joyBas.c
+++ b/src/libs/kpunch/joystick/joyBas.c
@@ -32,12 +32,10 @@
#include "joyInt.h"
#include "shellApp.h"
+#include "dvxBas.h"
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-
// QBasic's joysticks A and B: two axes and two buttons each, positions
// 1..200 (the classic game port's range)
#define QB_STICK_AXES 2
diff --git a/src/libs/kpunch/json/Makefile b/src/libs/kpunch/json/Makefile
new file mode 100644
index 00000000..051f0ec5
--- /dev/null
+++ b/src/libs/kpunch/json/Makefile
@@ -0,0 +1,61 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+# json.lib: reading JSON into a tree and finding things in it by path,
+# and quoting text for JSON (json.c), with its BASIC binding (jsonBas.c).
+
+include ../../../common.mk
+
+CFLAGS = $(DOS_CFLAGS) -I. -I../libdvx -I../libdvx/platform -I../libdvx/thirdparty
+OBJDIR = ../../../../obj/json
+LIBSDIR = ../../../../bin/libs
+
+SRCS = json.c jsonBas.c
+OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
+
+TARGETDIR = $(LIBSDIR)/kpunch/json
+TARGET = $(TARGETDIR)/json.lib
+
+.PHONY: all clean
+
+all: $(TARGET) $(TARGETDIR)/json.dep
+
+$(TARGETDIR)/json.dep: json.dep | $(TARGETDIR)
+ sed 's/$$/\r/' $< > $@
+
+$(TARGET): $(OBJS) | $(TARGETDIR)
+ $(DXE3GEN) -o $(TARGETDIR)/json.dxe -U $(OBJS)
+ mv $(TARGETDIR)/json.dxe $@
+
+$(OBJDIR)/%.o: %.c $(DVX_MAKEFILES) | $(OBJDIR)
+ $(CC) $(CFLAGS) -c -o $@ $<
+
+$(OBJDIR):
+ mkdir -p $(OBJDIR)
+
+$(TARGETDIR):
+ mkdir -p $(TARGETDIR)
+
+-include $(OBJS:.o=.d)
+
+clean:
+ rm -rf $(OBJS) $(OBJS:.o=.d) $(TARGET) $(TARGETDIR)/json.dep $(TARGETDIR)
diff --git a/src/libs/kpunch/json/json.c b/src/libs/kpunch/json/json.c
new file mode 100644
index 00000000..fc110fa7
--- /dev/null
+++ b/src/libs/kpunch/json/json.c
@@ -0,0 +1,695 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// json.c -- JSON for DVX (see json.h)
+//
+// A recursive-descent parser, JSON_DEPTH_MAX deep at most. Nodes live in
+// one array (a node's number is its index); each array or object keeps
+// the numbers of its items or members, so item N is found at once. The
+// decoded strings (values and member names) live in one pool, addressed
+// by offset because the pool moves as it grows.
+
+#include "json.h"
+#include "dvxTypes.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+
+#define NO_TEXT (-1) // a node without a name or text
+#define HEX_DIGITS_U 4 // the digits of a \u escape
+#define HEX_BASE 16
+#define DECIMAL_BASE 10
+#define ASCII_LIMIT 0x80 // \u escapes below this become their character
+#define CONTROL_LIMIT 0x20 // characters below this need escaping
+#define UNKNOWN_CHAR '?' // what other \u escapes become
+#define NUMBER_TEXT_MAX 64 // longest number read
+#define WORD_TRUE "true"
+#define WORD_FALSE "false"
+#define WORD_NULL "null"
+#define PATH_MEMBER_SEP '.'
+#define PATH_INDEX_OPEN '['
+#define PATH_INDEX_CLOSE ']'
+#define PATH_STOPS ".[" // what ends a member name in a path
+#define ESCAPE_TEXT_MAX 8 // the longest escape jsonQuote writes, \u00XX, and a NUL
+
+typedef struct {
+ JsonTypeE type;
+ int32_t key; // pool offset of a member's name, or NO_TEXT
+ int32_t text; // pool offset of a string's text, or NO_TEXT
+ int32_t *kids; // stb_ds: an array's items or an object's members
+ double number;
+ bool truth;
+} NodeT;
+
+struct JsonDocS {
+ NodeT *nodes; // stb_ds; node 0 is the root
+ char *pool; // stb_ds: NUL-terminated strings
+};
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int32_t line;
+ JsonDocT *doc;
+ char *err;
+ int32_t errSize;
+ bool failed;
+} ParserT;
+
+DVX_ABI_STAMP(json);
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static bool fail(ParserT *ps, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+static int32_t hexValue(char c);
+bool jsonBool(const JsonDocT *doc, int32_t node);
+int32_t jsonChild(const JsonDocT *doc, int32_t node, int32_t index);
+int32_t jsonCount(const JsonDocT *doc, int32_t node);
+int32_t jsonFind(const JsonDocT *doc, int32_t node, const char *path);
+void jsonFree(JsonDocT *doc);
+const char *jsonKey(const JsonDocT *doc, int32_t node);
+double jsonNumber(const JsonDocT *doc, int32_t node);
+JsonDocT *jsonParse(const char *text, int32_t len, char *err, int32_t errSize);
+int32_t jsonQuote(const char *text, char *out, int32_t outSize);
+const char *jsonText(const JsonDocT *doc, int32_t node);
+JsonTypeE jsonType(const JsonDocT *doc, int32_t node);
+static int32_t member(const JsonDocT *doc, int32_t node, const char *name, size_t len);
+static int32_t newNode(ParserT *ps, JsonTypeE type);
+static const NodeT *nodeOf(const JsonDocT *doc, int32_t node);
+static bool parseArray(ParserT *ps, int32_t node, int32_t depth);
+static bool parseNumber(ParserT *ps, int32_t node);
+static bool parseObject(ParserT *ps, int32_t node, int32_t depth);
+static bool parseString(ParserT *ps, int32_t *offset);
+static int32_t parseValue(ParserT *ps, int32_t depth);
+static bool parseWord(ParserT *ps, const char *word);
+static void quotePut(char *out, int32_t outSize, int32_t *n, const char *piece);
+static void skipSpace(ParserT *ps);
+
+
+static bool fail(ParserT *ps, const char *fmt, ...) {
+ char what[JSON_ERR_MAX];
+ va_list ap;
+
+ if (!ps->failed) {
+ va_start(ap, fmt);
+ vsnprintf(what, sizeof(what), fmt, ap);
+ va_end(ap);
+ snprintf(ps->err, (size_t)ps->errSize, "Line %d: %s", (int)ps->line, what);
+ }
+
+ ps->failed = true;
+ return false;
+}
+
+
+static int32_t hexValue(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + DECIMAL_BASE;
+ }
+
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + DECIMAL_BASE;
+ }
+
+ return -1;
+}
+
+
+bool jsonBool(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return n && n->type == JsonBoolE && n->truth;
+}
+
+
+int32_t jsonChild(const JsonDocT *doc, int32_t node, int32_t index) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return (n && index >= 0 && index < (int32_t)arrlen(n->kids)) ? n->kids[index] : JSON_NO_NODE;
+}
+
+
+int32_t jsonCount(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return n ? (int32_t)arrlen(n->kids) : 0;
+}
+
+
+int32_t jsonFind(const JsonDocT *doc, int32_t node, const char *path) {
+ const char *p = path ? path : "";
+
+ while (node != JSON_NO_NODE && *p) {
+ if (*p == PATH_INDEX_OPEN) {
+ char *end = NULL;
+ long index = strtol(p + 1, &end, DECIMAL_BASE);
+
+ if (end == p + 1 || *end != PATH_INDEX_CLOSE || index < 0 || jsonType(doc, node) != JsonArrayE) {
+ return JSON_NO_NODE;
+ }
+
+ node = jsonChild(doc, node, (int32_t)index);
+ p = end + 1;
+ } else {
+ size_t len;
+
+ if (*p == PATH_MEMBER_SEP) {
+ p++;
+ }
+
+ len = strcspn(p, PATH_STOPS);
+ node = member(doc, node, p, len);
+ p += len;
+ }
+ }
+
+ return node;
+}
+
+
+void jsonFree(JsonDocT *doc) {
+ if (!doc) {
+ return;
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(doc->nodes); i++) {
+ arrfree(doc->nodes[i].kids);
+ }
+
+ arrfree(doc->nodes);
+ arrfree(doc->pool);
+ free(doc);
+}
+
+
+const char *jsonKey(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return (n && n->key != NO_TEXT) ? &doc->pool[n->key] : "";
+}
+
+
+double jsonNumber(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return (n && n->type == JsonNumberE) ? n->number : 0.0;
+}
+
+
+JsonDocT *jsonParse(const char *text, int32_t len, char *err, int32_t errSize) {
+ JsonDocT *doc = (JsonDocT *)calloc(1, sizeof(JsonDocT));
+ ParserT ps;
+
+ if (!doc) {
+ snprintf(err, (size_t)errSize, "There is not enough memory.");
+ return NULL;
+ }
+
+ memset(&ps, 0, sizeof(ps));
+ ps.p = text ? text : "";
+ ps.end = ps.p + (text ? len : 0);
+ ps.line = 1;
+ ps.doc = doc;
+ ps.err = err;
+ ps.errSize = errSize;
+
+ if (parseValue(&ps, 0) != JSON_NO_NODE) {
+ skipSpace(&ps);
+
+ if (ps.p < ps.end) {
+ fail(&ps, "there is more after the end of the value");
+ }
+ }
+
+ if (ps.failed) {
+ jsonFree(doc);
+ return NULL;
+ }
+
+ return doc;
+}
+
+
+int32_t jsonQuote(const char *text, char *out, int32_t outSize) {
+ int32_t n = 0;
+
+ if (outSize > 0) {
+ out[0] = '\0';
+ }
+
+ quotePut(out, outSize, &n, "\"");
+
+ for (const unsigned char *c = (const unsigned char *)(text ? text : ""); *c; c++) {
+ char esc[ESCAPE_TEXT_MAX];
+
+ switch (*c) {
+ case '"':
+ quotePut(out, outSize, &n, "\\\"");
+ break;
+
+ case '\\':
+ quotePut(out, outSize, &n, "\\\\");
+ break;
+
+ case '\n':
+ quotePut(out, outSize, &n, "\\n");
+ break;
+
+ case '\r':
+ quotePut(out, outSize, &n, "\\r");
+ break;
+
+ case '\t':
+ quotePut(out, outSize, &n, "\\t");
+ break;
+
+ default:
+ if (*c < CONTROL_LIMIT) {
+ snprintf(esc, sizeof(esc), "\\u%04x", (unsigned)*c);
+ } else {
+ esc[0] = (char)*c;
+ esc[1] = '\0';
+ }
+
+ quotePut(out, outSize, &n, esc);
+ break;
+ }
+ }
+
+ quotePut(out, outSize, &n, "\"");
+ return n;
+}
+
+
+const char *jsonText(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return (n && n->text != NO_TEXT) ? &doc->pool[n->text] : "";
+}
+
+
+JsonTypeE jsonType(const JsonDocT *doc, int32_t node) {
+ const NodeT *n = nodeOf(doc, node);
+
+ return n ? n->type : JsonNoneE;
+}
+
+
+// The member of object node called name (len bytes), the first when the
+// name repeats; JSON_NO_NODE when there is none, or node is no object.
+static int32_t member(const JsonDocT *doc, int32_t node, const char *name, size_t len) {
+ const NodeT *n = nodeOf(doc, node);
+
+ if (!n || n->type != JsonObjectE) {
+ return JSON_NO_NODE;
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(n->kids); i++) {
+ const char *key = jsonKey(doc, n->kids[i]);
+
+ if (strlen(key) == len && memcmp(key, name, len) == 0) {
+ return n->kids[i];
+ }
+ }
+
+ return JSON_NO_NODE;
+}
+
+
+// A new node of type: its number.
+static int32_t newNode(ParserT *ps, JsonTypeE type) {
+ NodeT n;
+
+ memset(&n, 0, sizeof(n));
+ n.type = type;
+ n.key = NO_TEXT;
+ n.text = NO_TEXT;
+ arrput(ps->doc->nodes, n);
+ return (int32_t)arrlen(ps->doc->nodes) - 1;
+}
+
+
+static const NodeT *nodeOf(const JsonDocT *doc, int32_t node) {
+ return (doc && node >= 0 && node < (int32_t)arrlen(doc->nodes)) ? &doc->nodes[node] : NULL;
+}
+
+
+// The items of an array, after its '['.
+static bool parseArray(ParserT *ps, int32_t node, int32_t depth) {
+ skipSpace(ps);
+
+ if (ps->p < ps->end && *ps->p == ']') {
+ ps->p++;
+ return true;
+ }
+
+ for (;;) {
+ int32_t item = parseValue(ps, depth + 1);
+
+ if (item == JSON_NO_NODE) {
+ return false;
+ }
+
+ arrput(ps->doc->nodes[node].kids, item);
+ skipSpace(ps);
+
+ if (ps->p < ps->end && *ps->p == ',') {
+ ps->p++;
+ } else if (ps->p < ps->end && *ps->p == ']') {
+ ps->p++;
+ return true;
+ } else {
+ return fail(ps, "expected , or ] in an array");
+ }
+ }
+}
+
+
+// A number: JSON's form checked, then read.
+static bool parseNumber(ParserT *ps, int32_t node) {
+ const char *start = ps->p;
+ char text[NUMBER_TEXT_MAX];
+ int32_t digits;
+
+ if (ps->p < ps->end && *ps->p == '-') {
+ ps->p++;
+ }
+
+ for (digits = 0; ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9'; digits++) {
+ ps->p++;
+ }
+
+ if (digits == 0 || (digits > 1 && start[start[0] == '-' ? 1 : 0] == '0')) {
+ return fail(ps, "a number is not written right");
+ }
+
+ if (ps->p < ps->end && *ps->p == '.') {
+ ps->p++;
+
+ for (digits = 0; ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9'; digits++) {
+ ps->p++;
+ }
+
+ if (digits == 0) {
+ return fail(ps, "a number is not written right");
+ }
+ }
+
+ if (ps->p < ps->end && (*ps->p == 'e' || *ps->p == 'E')) {
+ ps->p++;
+
+ if (ps->p < ps->end && (*ps->p == '+' || *ps->p == '-')) {
+ ps->p++;
+ }
+
+ for (digits = 0; ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9'; digits++) {
+ ps->p++;
+ }
+
+ if (digits == 0) {
+ return fail(ps, "a number is not written right");
+ }
+ }
+
+ if (ps->p - start >= NUMBER_TEXT_MAX) {
+ return fail(ps, "a number is too long");
+ }
+
+ memcpy(text, start, (size_t)(ps->p - start));
+ text[ps->p - start] = '\0';
+ ps->doc->nodes[node].number = strtod(text, NULL);
+ return true;
+}
+
+
+// The members of an object, after its '{'.
+static bool parseObject(ParserT *ps, int32_t node, int32_t depth) {
+ skipSpace(ps);
+
+ if (ps->p < ps->end && *ps->p == '}') {
+ ps->p++;
+ return true;
+ }
+
+ for (;;) {
+ int32_t key = NO_TEXT;
+ int32_t value;
+
+ skipSpace(ps);
+
+ if (ps->p >= ps->end || *ps->p != '"') {
+ return fail(ps, "expected a member name in quotes");
+ }
+
+ ps->p++;
+
+ if (!parseString(ps, &key)) {
+ return false;
+ }
+
+ skipSpace(ps);
+
+ if (ps->p >= ps->end || *ps->p != ':') {
+ return fail(ps, "expected : after a member name");
+ }
+
+ ps->p++;
+ value = parseValue(ps, depth + 1);
+
+ if (value == JSON_NO_NODE) {
+ return false;
+ }
+
+ ps->doc->nodes[value].key = key;
+ arrput(ps->doc->nodes[node].kids, value);
+ skipSpace(ps);
+
+ if (ps->p < ps->end && *ps->p == ',') {
+ ps->p++;
+ } else if (ps->p < ps->end && *ps->p == '}') {
+ ps->p++;
+ return true;
+ } else {
+ return fail(ps, "expected , or } in an object");
+ }
+ }
+}
+
+
+// A string, after its opening quote, decoded into the pool: *offset is
+// where.
+static bool parseString(ParserT *ps, int32_t *offset) {
+ *offset = (int32_t)arrlen(ps->doc->pool);
+
+ while (ps->p < ps->end && *ps->p != '"') {
+ unsigned char c = (unsigned char)*ps->p++;
+
+ if (c < CONTROL_LIMIT) {
+ return fail(ps, "a string holds a line end or another control character");
+ }
+
+ if (c == '\\') {
+ if (ps->p >= ps->end) {
+ break;
+ }
+
+ c = (unsigned char)*ps->p++;
+
+ switch (c) {
+ case 'n':
+ c = '\n';
+ break;
+
+ case 't':
+ c = '\t';
+ break;
+
+ case 'r':
+ c = '\r';
+ break;
+
+ case 'b':
+ c = '\b';
+ break;
+
+ case 'f':
+ c = '\f';
+ break;
+
+ case '"':
+ case '\\':
+ case '/':
+ break;
+
+ case 'u': {
+ int32_t code = 0;
+
+ for (int32_t i = 0; i < HEX_DIGITS_U; i++) {
+ int32_t digit = ps->p < ps->end ? hexValue(*ps->p) : -1;
+
+ if (digit < 0) {
+ return fail(ps, "a \\u escape needs four hex digits");
+ }
+
+ code = code * HEX_BASE + digit;
+ ps->p++;
+ }
+
+ c = code < ASCII_LIMIT ? (unsigned char)code : (unsigned char)UNKNOWN_CHAR;
+ break;
+ }
+
+ default:
+ return fail(ps, "a string has an escape JSON does not have");
+ }
+ }
+
+ arrput(ps->doc->pool, (char)c);
+ }
+
+ if (ps->p >= ps->end) {
+ return fail(ps, "a string is not closed");
+ }
+
+ ps->p++;
+ arrput(ps->doc->pool, '\0');
+ return true;
+}
+
+
+// One value, its node's number; JSON_NO_NODE when it is not one (the
+// reason said).
+static int32_t parseValue(ParserT *ps, int32_t depth) {
+ int32_t node = JSON_NO_NODE;
+ bool ok = true;
+
+ skipSpace(ps);
+
+ if (ps->p < ps->end && (*ps->p == '[' || *ps->p == '{') && depth >= JSON_DEPTH_MAX) {
+ fail(ps, "arrays and objects are nested more than %d deep", JSON_DEPTH_MAX);
+ return JSON_NO_NODE;
+ }
+
+ if (ps->p >= ps->end) {
+ fail(ps, "expected a value");
+ return JSON_NO_NODE;
+ }
+
+ switch (*ps->p) {
+ case '{':
+ ps->p++;
+ node = newNode(ps, JsonObjectE);
+ ok = parseObject(ps, node, depth);
+ break;
+
+ case '[':
+ ps->p++;
+ node = newNode(ps, JsonArrayE);
+ ok = parseArray(ps, node, depth);
+ break;
+
+ case '"': {
+ int32_t text = NO_TEXT;
+
+ ps->p++;
+ ok = parseString(ps, &text);
+ node = newNode(ps, JsonStringE);
+ ps->doc->nodes[node].text = text;
+ break;
+ }
+
+ case 't':
+ node = newNode(ps, JsonBoolE);
+ ok = parseWord(ps, WORD_TRUE);
+ ps->doc->nodes[node].truth = true;
+ break;
+
+ case 'f':
+ node = newNode(ps, JsonBoolE);
+ ok = parseWord(ps, WORD_FALSE);
+ break;
+
+ case 'n':
+ node = newNode(ps, JsonNullE);
+ ok = parseWord(ps, WORD_NULL);
+ break;
+
+ default:
+ if (*ps->p != '-' && (*ps->p < '0' || *ps->p > '9')) {
+ fail(ps, "expected a value");
+ return JSON_NO_NODE;
+ }
+
+ node = newNode(ps, JsonNumberE);
+ ok = parseNumber(ps, node);
+ break;
+ }
+
+ return ok ? node : JSON_NO_NODE;
+}
+
+
+// One of the words true, false, and null.
+static bool parseWord(ParserT *ps, const char *word) {
+ size_t len = strlen(word);
+
+ if ((size_t)(ps->end - ps->p) < len || memcmp(ps->p, word, len) != 0) {
+ return fail(ps, "expected a value");
+ }
+
+ ps->p += len;
+ return true;
+}
+
+
+// Add piece to what jsonQuote writes: the bytes that fit, all counted.
+static void quotePut(char *out, int32_t outSize, int32_t *n, const char *piece) {
+ for (; *piece; piece++) {
+ if (*n + 1 < outSize) {
+ out[*n] = *piece;
+ out[*n + 1] = '\0';
+ }
+
+ (*n)++;
+ }
+}
+
+
+// Past spaces, tabs, and line ends (counting the lines).
+static void skipSpace(ParserT *ps) {
+ while (ps->p < ps->end && (*ps->p == ' ' || *ps->p == '\t' || *ps->p == '\r' || *ps->p == '\n')) {
+ if (*ps->p == '\n') {
+ ps->line++;
+ }
+
+ ps->p++;
+ }
+}
diff --git a/src/libs/kpunch/json/json.dep b/src/libs/kpunch/json/json.dep
new file mode 100644
index 00000000..d613d36a
--- /dev/null
+++ b/src/libs/kpunch/json/json.dep
@@ -0,0 +1 @@
+libdvx
diff --git a/src/libs/kpunch/json/json.dhs b/src/libs/kpunch/json/json.dhs
new file mode 100644
index 00000000..785a220d
--- /dev/null
+++ b/src/libs/kpunch/json/json.dhs
@@ -0,0 +1,76 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+.section Technical Reference (for Developers)|Libraries
+.topic lib.json
+.title JSON Library
+.toc 0 JSON Library
+.index json
+.index JSON
+.index jsonParse
+.index jsonFind
+.index jsonQuote
+.index json.bas
+
+.h1 JSON Library
+
+Reads JSON text -- a web service's reply, a settings file -- into a tree, finds values in it by path, and quotes text for JSON of your own. The package server's replies are read with it.
+
+Header: json/json.h
+
+Loaded as: bin/libs/kpunch/json/json.lib
+
+BASIC: json.bas (JsonParse, JsonText$, JsonNumber, JsonCount, JsonKey$, JsonQuote$, ...).
+
+.h2 Reading
+
+jsonParse(text, len, err, errSize) reads a document and returns it (NULL when it is not JSON: err says why and on which line; jsonFree frees it). Its nodes are numbered from 0, the root. jsonFind(doc, node, path) finds a node from another: a path is member names and [index]es, such as packages[3].version, [0], or "" (the node itself); members match by exact name, the first when a name repeats. JSON_NO_NODE stands for a node that is not there, and every function accepts it.
+
+.table
+ Function Description
+ -------- -----------
+ jsonType(doc, node) JsonNullE, JsonBoolE, JsonNumberE, JsonStringE, JsonArrayE, JsonObjectE; JsonNoneE for no node.
+ jsonText(doc, node) A string's text ("" for anything else).
+ jsonNumber(doc, node) A number's value, a double (0 for anything else).
+ jsonBool(doc, node) True only for true.
+ jsonCount(doc, node) An array's items or an object's members (0 for anything else).
+ jsonChild(doc, node, index) Item or member index, in order.
+ jsonKey(doc, node) A member's name ("" for anything else).
+.endtable
+
+Strings are held decoded: the escapes \n, \t, \", \\, \/, \b, \f, and \r become their characters, \u escapes below 128 their character and others '?'; other bytes, UTF-8 included, pass through. Arrays and objects nest JSON_DEPTH_MAX (64) deep at most.
+
+.code
+char err[JSON_ERR_MAX];
+JsonDocT *doc = jsonParse(text, len, err, sizeof(err));
+
+for (int32_t i = 0; doc && i < jsonCount(doc, jsonFind(doc, 0, "packages")); i++) {
+ int32_t pkg = jsonChild(doc, jsonFind(doc, 0, "packages"), i);
+
+ printf("%s %s\n", jsonText(doc, jsonFind(doc, pkg, "id")), jsonText(doc, jsonFind(doc, pkg, "version")));
+}
+
+jsonFree(doc);
+.endcode
+
+.h2 Writing
+
+jsonQuote(text, out, outSize) writes text as a JSON string -- quotes, escapes, control characters as \u00XX -- and returns the length it needs (as snprintf does), so a NULL out with size 0 measures it.
diff --git a/src/libs/kpunch/json/json.h b/src/libs/kpunch/json/json.h
new file mode 100644
index 00000000..fb7de20c
--- /dev/null
+++ b/src/libs/kpunch/json/json.h
@@ -0,0 +1,100 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// json.h -- JSON for DVX (LIBS\KPUNCH\JSON\JSON.LIB): reading a document
+// (a web service's reply, a settings file) into a tree and looking things
+// up in it by path, and quoting text for a document of your own.
+//
+// A parsed document is a set of nodes numbered from 0, the root. A node
+// is found by a path from another node:
+//
+// packages[3].version member "packages", its item 3, member "version"
+// [0] item 0 of an array
+// "" the node itself
+//
+// Members are matched by exact name (the first when a name repeats). A
+// name holding '.' or '[' is reached with jsonChild instead. Strings are
+// held decoded: \n, \t, \", \\, \/, \b, \f, \r, and \u escapes below 128
+// become their characters, \u escapes of other characters become '?', and
+// other bytes (UTF-8 included) pass through unchanged. Numbers are
+// doubles.
+
+#ifndef JSON_H
+#define JSON_H
+
+#include
+#include
+
+#define JSON_ERR_MAX 96 // longest reason jsonParse gives
+#define JSON_DEPTH_MAX 64 // arrays and objects nested deeper are refused
+#define JSON_NO_NODE (-1) // what lookups give for a node that is not there
+
+typedef enum {
+ JsonNoneE, // no such node (a path that leads nowhere)
+ JsonNullE,
+ JsonBoolE,
+ JsonNumberE,
+ JsonStringE,
+ JsonArrayE,
+ JsonObjectE
+} JsonTypeE;
+
+typedef struct JsonDocS JsonDocT;
+
+// True of a true node.
+bool jsonBool(const JsonDocT *doc, int32_t node);
+
+// Item or member index of an array or object node (JSON_NO_NODE past the
+// end, or for any other node).
+int32_t jsonChild(const JsonDocT *doc, int32_t node, int32_t index);
+
+// How many items or members an array or object node has (0 for any other
+// node).
+int32_t jsonCount(const JsonDocT *doc, int32_t node);
+
+// The node path leads to from node (JSON_NO_NODE when there is none).
+int32_t jsonFind(const JsonDocT *doc, int32_t node, const char *path);
+
+void jsonFree(JsonDocT *doc);
+
+// A member node's name ("" for any other node).
+const char *jsonKey(const JsonDocT *doc, int32_t node);
+
+// A number node's value (0 for any other node).
+double jsonNumber(const JsonDocT *doc, int32_t node);
+
+// Read len bytes of JSON text. NULL with the reason in err (at the line
+// it is on) when it is not JSON, nests deeper than JSON_DEPTH_MAX, or
+// memory runs out.
+JsonDocT *jsonParse(const char *text, int32_t len, char *err, int32_t errSize);
+
+// text as a JSON string, quotes and escapes included, into out (outSize
+// bytes, NUL-terminated, cut short when it does not fit): the length it
+// needs without the NUL, as snprintf counts.
+int32_t jsonQuote(const char *text, char *out, int32_t outSize);
+
+// A string node's text ("" for any other node).
+const char *jsonText(const JsonDocT *doc, int32_t node);
+
+JsonTypeE jsonType(const JsonDocT *doc, int32_t node);
+
+#endif // JSON_H
diff --git a/src/libs/kpunch/json/jsonBas.c b/src/libs/kpunch/json/jsonBas.c
new file mode 100644
index 00000000..46e63c6d
--- /dev/null
+++ b/src/libs/kpunch/json/jsonBas.c
@@ -0,0 +1,179 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// jsonBas.c -- json.bas: JSON for DVX BASIC programs
+//
+// JsonParse gives a handle (0 when the text is not JSON: JsonError$ says
+// why); everything else names a node by its path from the root, as
+// jsonFind does ("packages[3].version"; "" is the root). A program's
+// documents are freed for it when it ends.
+
+#include "jsonBas.h"
+#include "json.h"
+#include "dvxBas.h"
+
+#include
+#include
+#include
+
+#define JSON_ROOT 0 // the root node of every document
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static void docFree(void *obj);
+int32_t JsonBool(int32_t h, const char *path);
+int32_t JsonCount(int32_t h, const char *path);
+const char *JsonError(void);
+void JsonFree(int32_t h);
+const char *JsonKey(int32_t h, const char *path, int32_t index);
+double JsonNumber(int32_t h, const char *path);
+int32_t JsonParse(const char *text);
+const char *JsonQuote(const char *text);
+const char *JsonText(int32_t h, const char *path);
+int32_t JsonType(int32_t h, const char *path);
+static int32_t nodeAt(int32_t h, const char *path, const JsonDocT **doc);
+
+// ============================================================
+// Module state
+// ============================================================
+
+static BasHandlesT sDocs = BAS_HANDLES(docFree, 0); // the open documents
+static BasTextT sText; // the last string result
+static char sError[JSON_ERR_MAX]; // why the last JsonParse failed
+
+
+// A document (the handles' freeFn).
+static void docFree(void *obj) {
+ jsonFree((JsonDocT *)obj);
+}
+
+
+// True when the node at path is true.
+int32_t JsonBool(int32_t h, const char *path) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return jsonBool(doc, node) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+// The items or members of the array or object at path.
+int32_t JsonCount(int32_t h, const char *path) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return jsonCount(doc, node);
+}
+
+
+// Why the last JsonParse failed ("" when it did not).
+const char *JsonError(void) {
+ return basTextSet(&sText, sError);
+}
+
+
+void JsonFree(int32_t h) {
+ basHandleFree(&sDocs, h);
+}
+
+
+// The name of member index of the object at path ("" past the last).
+const char *JsonKey(int32_t h, const char *path, int32_t index) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return basTextSet(&sText, jsonKey(doc, jsonChild(doc, node, index)));
+}
+
+
+double JsonNumber(int32_t h, const char *path) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return jsonNumber(doc, node);
+}
+
+
+// Read JSON text: a handle, or 0 (JsonError$ says why).
+int32_t JsonParse(const char *text) {
+ JsonDocT *doc = jsonParse(text, text ? (int32_t)strlen(text) : 0, sError, sizeof(sError));
+ int32_t h;
+
+ if (!doc) {
+ return BAS_NO_HANDLE;
+ }
+
+ h = basHandleAdd(&sDocs, doc);
+
+ if (h == BAS_NO_HANDLE) {
+ jsonFree(doc);
+ snprintf(sError, sizeof(sError), "There is not enough memory.");
+ return BAS_NO_HANDLE;
+ }
+
+ sError[0] = '\0';
+ return h;
+}
+
+
+// text as a JSON string, quoted and escaped.
+const char *JsonQuote(const char *text) {
+ int32_t len = jsonQuote(text, NULL, 0);
+ char *buf = (char *)malloc((size_t)len + 1);
+
+ if (!buf) {
+ return basTextSet(&sText, "");
+ }
+
+ jsonQuote(text, buf, len + 1);
+ basTextSet(&sText, buf);
+ free(buf);
+ return basTextEnd(&sText);
+}
+
+
+// The text of the string at path.
+const char *JsonText(int32_t h, const char *path) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return basTextSet(&sText, jsonText(doc, node));
+}
+
+
+// What the node at path is (JSON_*; JSON_NONE when there is none).
+int32_t JsonType(int32_t h, const char *path) {
+ const JsonDocT *doc = NULL;
+ int32_t node = nodeAt(h, path, &doc);
+
+ return (int32_t)jsonType(doc, node);
+}
+
+
+// The node at path in document h (*doc set; JSON_NO_NODE for a bad
+// handle or a path to nowhere).
+static int32_t nodeAt(int32_t h, const char *path, const JsonDocT **doc) {
+ *doc = (const JsonDocT *)basHandleGet(&sDocs, h);
+ return *doc ? jsonFind(*doc, JSON_ROOT, path) : JSON_NO_NODE;
+}
diff --git a/src/libs/kpunch/json/jsonBas.h b/src/libs/kpunch/json/jsonBas.h
new file mode 100644
index 00000000..b4f07064
--- /dev/null
+++ b/src/libs/kpunch/json/jsonBas.h
@@ -0,0 +1,42 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// jsonBas.h -- the C side of json.bas (jsonBas.c): the functions BASIC
+// calls. json.bas's JSON_* types are JsonTypeE's numbers (json.h).
+
+#ifndef JSON_BAS_H
+#define JSON_BAS_H
+
+#include
+
+int32_t JsonBool(int32_t h, const char *path);
+int32_t JsonCount(int32_t h, const char *path);
+const char *JsonError(void);
+void JsonFree(int32_t h);
+const char *JsonKey(int32_t h, const char *path, int32_t index);
+double JsonNumber(int32_t h, const char *path);
+int32_t JsonParse(const char *text);
+const char *JsonQuote(const char *text);
+const char *JsonText(int32_t h, const char *path);
+int32_t JsonType(int32_t h, const char *path);
+
+#endif // JSON_BAS_H
diff --git a/src/libs/kpunch/libdvx/Makefile b/src/libs/kpunch/libdvx/Makefile
index adcbba3a..f51e2461 100644
--- a/src/libs/kpunch/libdvx/Makefile
+++ b/src/libs/kpunch/libdvx/Makefile
@@ -36,7 +36,7 @@ LIBSDIR = ../../../../bin/libs
# Core sources. dvxPrefs.c is not among them on DOS: the loader needs it
# before libdvx loads, so dvx.exe links it and exports prefs* to every DXE.
SRCS = dvxVideo.c dvxDraw.c dvxComp.c dvxWm.c dvxImage.c dvxImageWrite.c dvxDriver.c \
- dvxApp.c dvxDialog.c dvxPrefsBas.c dvxResource.c dvxText.c dvxTextBas.c dvxFontFmt.c dvxFlow.c \
+ dvxApp.c dvxBas.c dvxDialog.c dvxPrefsBas.c dvxResource.c dvxText.c dvxTextBas.c dvxFontFmt.c dvxFlow.c \
widgetClass.c widgetCore.c widgetScrollbar.c \
widgetLayout.c widgetEvent.c widgetOps.c widgetIface.c
diff --git a/src/libs/kpunch/libdvx/apiref.dhs b/src/libs/kpunch/libdvx/apiref.dhs
index ac731531..982ef2b5 100644
--- a/src/libs/kpunch/libdvx/apiref.dhs
+++ b/src/libs/kpunch/libdvx/apiref.dhs
@@ -5702,6 +5702,7 @@ Describes a filter entry in the file dialog's format dropdown. The glob pattern
------ ----- -----------
FD_OPEN 0x0000 File open dialog (default)
FD_SAVE 0x0001 File save dialog (with overwrite prompt)
+ FD_FOLDER 0x0002 Choose a folder, new or existing
.endtable
.h3 dvxFileDialog
@@ -5712,12 +5713,14 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
Display a modal file open/save dialog. Shows a directory listing with parent/drive-letter navigation, a drive dropdown, a filename input, and an optional filter dropdown. The path box always shows the current folder, and the drive dropdown (platformDriveList, so listing the drives never touches empty floppy or CD-ROM drives) its drive; choosing a drive goes to its root, and a drive that cannot be read shows "Drive X: is not ready." and leaves the dialog where it was. Typing "D:" or "D:folder" in the path box changes drive/folder; the file name box also accepts a relative or absolute path ("sub\x.txt", "C:\X\A.TXT") and navigates to its folder. A folder that does not exist shows an "Invalid Path" error. The dialog closes at once if the app that owns it is ended.
+With FD_FOLDER the dialog chooses a folder: the list shows folders only (filters are not used), the name box is "Folder name", and OK returns the folder shown, or the folder of the typed name in it (or the typed path itself when it is absolute). That folder need not exist yet; the caller makes it (platformMkdirRecursive). A name that is a file shows an "Invalid Folder" error, and so does a new name the disk cannot hold. An initialDir that does not exist yet opens at its nearest existing folder with the rest already in the name box, so OK alone returns initialDir unchanged. The default title is "Choose a Folder".
+
.table
Parameter Description
--------- -----------
ctx Application context
- title Window title
- flags FD_OPEN or FD_SAVE
+ title Window title (NULL = "Open", "Save As", or "Choose a Folder")
+ flags FD_OPEN, FD_SAVE, or FD_FOLDER
initialDir Starting directory (NULL = current working directory)
filters Array of FileFilterT, or NULL
filterCount Number of filter entries
@@ -5725,7 +5728,7 @@ Display a modal file open/save dialog. Shows a directory listing with parent/dri
outPathSize Capacity of outPath buffer
.endtable
-Returns: true if the user selected a file, false if cancelled or closed.
+Returns: true if the user selected a file (or folder), false if cancelled or closed.
.h2 Input Boxes
@@ -6128,6 +6131,8 @@ Returns: 0 on success, -1 if not found, the name is too long, or I/O failed.
.index prefsSetString
.index prefsSetInt
.index prefsSetBool
+.index prefsGetSecret
+.index prefsSetSecret
.index prefsGetVideoMode
.index prefsSetVideoMode
.index DVX_CONFIG_DIR
@@ -6148,7 +6153,7 @@ Returns: 0 on success, -1 if not found, the name is too long, or I/O failed.
Handle-based API over classic INI files. Each prefsLoad/prefsCreate returns a PrefsHandleT that must be passed to all subsequent calls and freed with prefsClose when done. Multiple INI files can be open simultaneously.
-BASIC programs reach the same functions through prefs.bas (dvxPrefsBas.c in libdvx): PrefsLoad, PrefsCreate, PrefsGet$, PrefsGetInt, PrefsGetBool, PrefsSet, PrefsSetInt, PrefsSetBool, PrefsRemove, PrefsKeyName$ (prefsKeyName, "" past the last key), PrefsSave, PrefsSaveAs, and PrefsClose, on handles (1, 2, ...; 0 = none) that belong to the program that made them and are closed, unsaved, when it ends. True is -1, False 0. PrefsDvxIni$ is platformDvxIniPath() (the user folder's DVX.INI), PrefsSetVideoMode is prefsSetVideoMode, and PrefsVideoWidth / PrefsVideoHeight / PrefsVideoBpp are the three values of prefsGetVideoMode (DVX_DEFAULT_VIDEO_* for a missing key or a bad handle). The choice tables below are PREFS_CHOICE_ACCEL (0), PREFS_CHOICE_WALLPAPER (1), and PREFS_CHOICE_WHEEL (2) there: PrefsChoiceCount, PrefsChoiceLabel$, PrefsChoiceName$ (iniName), PrefsChoiceValue, PrefsChoiceIndex, PrefsChoiceIndexOfValue, PrefsChoiceDefault (the index of the choice MOUSE_ACCEL_DEFAULT / WALLPAPER_MODE_DEFAULT / MOUSE_WHEEL_DIR_DEFAULT names; -1 for an unknown table), and PrefsGetChoice(handle, section, key, table) with the table's default. PrefsGetChoiceFrom(handle, section, key, choiceNames, defaultName) is prefsGetChoice on a table the program gives as "|"-separated names (each choice's iniName and label is its name, its value its index).
+BASIC programs reach the same functions through prefs.bas (dvxPrefsBas.c in libdvx): PrefsLoad, PrefsCreate, PrefsGet$, PrefsGetInt, PrefsGetBool, PrefsSet, PrefsSetInt, PrefsSetBool, PrefsGetSecret$, PrefsSetSecret, PrefsRemove, PrefsKeyName$ (prefsKeyName, "" past the last key), PrefsSave, PrefsSaveAs, and PrefsClose, on handles (1, 2, ...; 0 = none) that belong to the program that made them and are closed, unsaved, when it ends. True is -1, False 0. PrefsDvxIni$ is platformDvxIniPath() (the user folder's DVX.INI), PrefsSetVideoMode is prefsSetVideoMode, and PrefsVideoWidth / PrefsVideoHeight / PrefsVideoBpp are the three values of prefsGetVideoMode (DVX_DEFAULT_VIDEO_* for a missing key or a bad handle). The choice tables below are PREFS_CHOICE_ACCEL (0), PREFS_CHOICE_WALLPAPER (1), and PREFS_CHOICE_WHEEL (2) there: PrefsChoiceCount, PrefsChoiceLabel$, PrefsChoiceName$ (iniName), PrefsChoiceValue, PrefsChoiceIndex, PrefsChoiceIndexOfValue, PrefsChoiceDefault (the index of the choice MOUSE_ACCEL_DEFAULT / WALLPAPER_MODE_DEFAULT / MOUSE_WHEEL_DIR_DEFAULT names; -1 for an unknown table), and PrefsGetChoice(handle, section, key, table) with the table's default. PrefsGetChoiceFrom(handle, section, key, choiceNames, defaultName) is prefsGetChoice on a table the program gives as "|"-separated names (each choice's iniName and label is its name, its value its index).
.code
PrefsHandleT *prefsBasHandle(int32_t handle);
@@ -6310,6 +6315,24 @@ Retrieve a boolean value. The whole value must be "true", "yes", or "1" (true) o
Returns: Parsed bool, or defaultVal.
+.h3 prefsGetSecret
+
+.code
+void prefsGetSecret(PrefsHandleT *h, const char *section, const char *key, char *out, int32_t outSize);
+.endcode
+
+Read back a secret prefsSetSecret stored, into out (cut to outSize - 1 characters and always terminated; nothing is written when outSize is 0 or less). A missing key, or a NULL handle, gives "". A value prefsSetSecret did not write (one typed into the file by hand: no leading "*", or not hex digits after it) is copied as it is, so a password can be put in the file by hand and is scrambled the next time the program saves it.
+
+.table
+ Parameter Description
+ --------- -----------
+ h Preferences handle
+ section Section name
+ key Key name
+ out Receives the text
+ outSize Size of out in bytes
+.endtable
+
.h3 prefsGetVideoMode / prefsSetVideoMode
.code
@@ -6345,6 +6368,14 @@ void prefsSetBool(PrefsHandleT *h, const char *section, const char *key, bool va
Set a boolean value (stored as "true"/"false").
+.h3 prefsSetSecret
+
+.code
+void prefsSetSecret(PrefsHandleT *h, const char *section, const char *key, const char *value);
+.endcode
+
+Store a secret, such as a password, so it cannot be read at a glance: "*", a 32-bit salt as 8 hex digits, then each byte of the text XORed with an xorshift32 keystream started from the salt, as 2 hex digits. Each call takes a new salt (clock(), time(), and a call count), so the same text is stored differently each time. This is obfuscation, not encryption: anyone with this code can read the value back, so the file still needs the care of any file holding a password. An empty or NULL value is stored as "". prefsGetSecret reads it back.
+
.h3 prefsRemove
.code
@@ -7516,7 +7547,17 @@ Write path with its file-name extension replaced by ext into out, followed by as
bool platformReplaceFile(const char *tmpPath, const char *path, const char *bakPath);
.endcode
-Crash-safe swap of a fully written tmpPath in for path. The original is renamed aside to bakPath first (DOS rename cannot overwrite) and removed only once the replacement is in place; if the swap fails the original is restored and tmpPath removed. A directory at path is never moved aside: the call fails and removes tmpPath. A leftover bakPath is removed only while path exists; with path missing it may be the only copy left by a crash between the two renames, so it is kept (prefsLoad reads it back). The preferences system, the resource writer, and the help compiler all save this way, with the PLATFORM_TMP_EXT and PLATFORM_BAK_EXT siblings from platformPathSibling.
+Crash-safe swap of a fully written tmpPath in for path. The original is renamed aside to bakPath first (DOS rename cannot overwrite) and removed only once the replacement is in place; if the swap fails the original is restored and tmpPath removed. A directory at path is never moved aside: the call fails and removes tmpPath. A leftover bakPath is removed only while path exists; with path missing it may be the only copy left by a crash between the two renames, so it is kept (prefsLoad reads it back). The resource writer and the help compiler save this way, with the PLATFORM_TMP_EXT and PLATFORM_BAK_EXT siblings from platformPathSibling.
+
+.index platformWriteFile
+
+.h3 platformWriteFile
+
+.code
+bool platformWriteFile(const char *path, const void *data, int32_t len);
+.endcode
+
+Write len bytes of data as the whole of path, all or nothing: into the PLATFORM_TMP_EXT sibling, then swapped in with platformReplaceFile, so a full disk or an I/O error leaves the file as it was. False when it was not done. The preferences system, the IDE's saves, Setup, and the package records use it.
.h3 platformReadFile
@@ -7586,12 +7627,15 @@ platformBootDrive returns the letter of the drive DOS started from (INT 21h AX=3
#define DVX_SOUNDS_DIR "SOUNDS"
#define DVX_THEMES_DIR "THEMES"
#define DVX_WPAPER_DIR "WPAPER"
+#define DVX_BANKS_DIR "BANKS"
+#define DVX_VENDOR_DIR "KPUNCH"
typedef enum {
DvxKindFontsE,
DvxKindSoundsE,
DvxKindThemesE,
DvxKindWpaperE,
+ DvxKindBanksE,
DvxKindCountE
} DvxKindE;
@@ -7618,17 +7662,17 @@ The DVX folder (the working directory) holds the program, which Setup installs,
SYSTEM DVX's own tools and files, and the shipped DVX.INI and ASSOC.INI
LIBS, WIDGETS The libraries and widgets
APPS\\ A program, its help, and DATA (the files it ships; read-only)
- SHARED FONTS, SOUNDS, THEMES, and WPAPER: what DVX ships for every program
- USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, and WPAPER, APPS\ (each program's settings and saved files), and DOWNLOAD
+ SHARED FONTS, SOUNDS, THEMES, WPAPER, and BANKS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME)
+ USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, and BANKS, KEYS (publishers' keys the user trusts), APPS\ (each program's settings and saved files), and DOWNLOAD
.endtable
-Only DVX's own package puts files in SHARED. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.
+Only packages put files in SHARED: DVX's own, and add-on packages (pkg.lib), each in its vendor's folders. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.
platformUserInit, called once by the loader, decides the user folder: the folder the DVXUSER environment variable names, else USER in the DVX folder, made when missing. When it cannot be written (DVX on a CD-ROM without DVXUSER), DVXUSER in the TEMP folder is used instead and platformUserInTemp is true; without a TEMP folder nothing can be saved and platformIsReadOnly is true. The first start copies SYSTEM\DVX.INI to the user folder. platformUserDir is the folder (DVXUSER or USER before platformUserInit has decided); platformUserPath joins a name to it; platformDvxIniPath is its DVX.INI. platformUserDirOf is the user folder of a DVX installed in another folder (DVXUSER, else that folder's USER), for Setup.
platformAppUserDir is a program's folder in the user folder: APPS\KPUNCH\TERMINAL\TERMINAL.APP gives USER\APPS\KPUNCH\TERMINAL; a program outside the DVX folder gives USER\APPS\. It is not made here (the shell's userDir and BASIC's App.User are made on first use).
-SHARED and the user folder have the same four kinds of folder (DvxKindE). platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name, the user's first, so a user's file of the same name wins; a name with a folder in it is taken as the path it is. platformKindNameOf is the other way: a file directly in one of the kind's folders gives its name alone, which is what settings store (DVX.INI [sounds] and [desktop] wallpaper), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them; free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas), and App.User for a program's own folder.
+SHARED and the user folder have the same five kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, and MIDI instrument banks. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_BANKS), and App.User for a program's own folder.
.h3 Read-only media: platformSetReadOnly / platformIsReadOnly / platformDirIsWritable / platformTempDir / platformSetLogMemory / platformLogMemory
diff --git a/src/libs/kpunch/libdvx/dvxBas.c b/src/libs/kpunch/libdvx/dvxBas.c
new file mode 100644
index 00000000..c92a9f38
--- /dev/null
+++ b/src/libs/kpunch/libdvx/dvxBas.c
@@ -0,0 +1,193 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// dvxBas.c -- the BASIC bindings' shared handles and string results
+// (dvxBas.h)
+
+#include "dvxBas.h"
+#include "dvxMem.h"
+#include "dvxPlat.h"
+#include "thirdparty/stb_ds_wrap.h"
+
+#include
+#include
+
+#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
+
+typedef void (*BasCleanupFnT)(int32_t appId);
+typedef void (*BasCleanupRegFnT)(BasCleanupFnT fn);
+
+// Every table that has given a handle, and whether the shell calls
+// appEnded yet
+static BasHandlesT **sTables = NULL;
+static bool sHooked = false;
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static void appEnded(int32_t appId);
+int32_t basHandleAdd(BasHandlesT *t, void *obj);
+void basHandleFree(BasHandlesT *t, int32_t handle);
+void *basHandleGet(const BasHandlesT *t, int32_t handle);
+void basTextAppend(BasTextT *t, const char *s);
+void basTextAppendLen(BasTextT *t, const char *s, int32_t len);
+const char *basTextEnd(BasTextT *t);
+void basTextRow(BasTextT *t, const char *const *cells, int32_t count);
+const char *basTextSet(BasTextT *t, const char *s);
+void basTextStart(BasTextT *t);
+
+
+// The shell's app cleanup: what the program that ended still holds.
+static void appEnded(int32_t appId) {
+ for (int32_t t = 0; t < (int32_t)arrlen(sTables); t++) {
+ for (int32_t i = 0; i < (int32_t)arrlen(sTables[t]->objs); i++) {
+ if (sTables[t]->objs[i] && sTables[t]->owners[i] == appId) {
+ basHandleFree(sTables[t], i + 1);
+ }
+ }
+ }
+}
+
+
+int32_t basHandleAdd(BasHandlesT *t, void *obj) {
+ int32_t idx = 0;
+
+ if (!obj) {
+ return BAS_NO_HANDLE;
+ }
+
+ // The shell is found once its module is there (never on the host
+ // tests' own)
+ if (!sHooked) {
+ BasCleanupRegFnT reg = (BasCleanupRegFnT)dlsym(NULL, DVX_SYM(SHELL_CLEANUP_SYM));
+
+ sHooked = true;
+
+ if (reg) {
+ reg(appEnded);
+ }
+ }
+
+ if (!t->listed) {
+ arrput(sTables, t);
+ t->listed = (int32_t)arrlen(sTables) > 0 && sTables[arrlen(sTables) - 1] == t;
+ }
+
+ while (idx < (int32_t)arrlen(t->objs) && t->objs[idx]) {
+ idx++;
+ }
+
+ if (t->max > 0 && idx >= t->max) {
+ return BAS_NO_HANDLE;
+ }
+
+ if (idx == (int32_t)arrlen(t->objs)) {
+ arrput(t->objs, NULL);
+ arrput(t->owners, 0);
+
+ if (idx >= (int32_t)arrlen(t->objs) || idx >= (int32_t)arrlen(t->owners)) {
+ return BAS_NO_HANDLE;
+ }
+ }
+
+ t->objs[idx] = obj;
+ t->owners[idx] = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
+ return idx + 1;
+}
+
+
+void basHandleFree(BasHandlesT *t, int32_t handle) {
+ void *obj = basHandleGet(t, handle);
+
+ if (!obj) {
+ return;
+ }
+
+ t->objs[handle - 1] = NULL;
+
+ if (t->freeFn) {
+ t->freeFn(obj);
+ }
+}
+
+
+void *basHandleGet(const BasHandlesT *t, int32_t handle) {
+ if (handle <= BAS_NO_HANDLE || handle > (int32_t)arrlen(t->objs)) {
+ return NULL;
+ }
+
+ return t->objs[handle - 1];
+}
+
+
+void basTextAppend(BasTextT *t, const char *s) {
+ basTextAppendLen(t, s, (int32_t)strlen(s));
+}
+
+
+void basTextAppendLen(BasTextT *t, const char *s, int32_t len) {
+ for (int32_t i = 0; i < len; i++) {
+ arrput(t->buf, s[i]);
+ }
+}
+
+
+const char *basTextEnd(BasTextT *t) {
+ arrput(t->buf, '\0');
+
+ // Out of memory part way: the NUL may not be there
+ if (arrlen(t->buf) == 0 || t->buf[arrlen(t->buf) - 1] != '\0') {
+ arrsetlen(t->buf, 0);
+ return "";
+ }
+
+ return t->buf;
+}
+
+
+void basTextRow(BasTextT *t, const char *const *cells, int32_t count) {
+ for (int32_t i = 0; i < count; i++) {
+ if (i > 0) {
+ arrput(t->buf, BAS_ROW_CELL_SEP);
+ }
+
+ for (const char *c = cells[i]; *c; c++) {
+ arrput(t->buf, (*c == BAS_ROW_CELL_SEP || *c == BAS_ROW_END || *c == '\r') ? BAS_ROW_BLANK : *c);
+ }
+ }
+
+ arrput(t->buf, BAS_ROW_END);
+}
+
+
+const char *basTextSet(BasTextT *t, const char *s) {
+ basTextStart(t);
+ basTextAppend(t, s ? s : "");
+ return basTextEnd(t);
+}
+
+
+void basTextStart(BasTextT *t) {
+ arrsetlen(t->buf, 0);
+}
diff --git a/src/libs/kpunch/libdvx/dvxBas.h b/src/libs/kpunch/libdvx/dvxBas.h
new file mode 100644
index 00000000..5e1f2852
--- /dev/null
+++ b/src/libs/kpunch/libdvx/dvxBas.h
@@ -0,0 +1,97 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// dvxBas.h -- what every library's BASIC bindings share
+//
+// BASIC's truth values, the numbers ("handles") a library gives a BASIC
+// program for its objects, and the string a function returns to BASIC.
+//
+// A handle is 1, 2, ... (BAS_NO_HANDLE, 0, is none). Each belongs to the
+// program that made it (dvxMemAppIdPtr) and is freed with the table's
+// freeFn when that program ends, however it ends (the shell's app
+// cleanup), so a program that forgets one leaks nothing. A table is a
+// static BasHandlesT made with BAS_HANDLES(freeFn, max).
+//
+// A string result is kept in the library's BasTextT until its next
+// string result: BASIC copies it as soon as the function returns.
+
+#ifndef DVX_BAS_H
+#define DVX_BAS_H
+
+#include
+#include
+
+#define BAS_TRUE (-1)
+#define BAS_FALSE 0
+#define BAS_NO_HANDLE 0
+#define BAS_ROW_CELL_SEP '\t' // between a row's cells (what a ListView's SetRows takes)
+#define BAS_ROW_END '\n' // after each row
+#define BAS_ROW_BLANK ' ' // what a tab or line end within a cell becomes
+
+typedef void (*BasFreeFnT)(void *obj);
+
+typedef struct {
+ void **objs; // stb_ds array: NULL is a free slot
+ int32_t *owners; // stb_ds array, beside objs: the owning program
+ BasFreeFnT freeFn;
+ int32_t max; // most at once (0: no limit)
+ bool listed; // known to the app cleanup
+} BasHandlesT;
+
+typedef struct {
+ char *buf; // stb_ds array, NUL-terminated once ended
+} BasTextT;
+
+#define BAS_HANDLES(freeFn, max) { NULL, NULL, (freeFn), (max), false }
+
+// A handle for obj, owned by the running program: BAS_NO_HANDLE (obj not
+// freed) when obj is NULL, the table is full, or out of memory.
+int32_t basHandleAdd(BasHandlesT *t, void *obj);
+
+// Free handle's object (freeFn) and the handle; a bad handle is ignored.
+void basHandleFree(BasHandlesT *t, int32_t handle);
+
+// The object of handle, or NULL for a bad handle.
+void *basHandleGet(const BasHandlesT *t, int32_t handle);
+
+// Add s to the result being built (basTextStart).
+void basTextAppend(BasTextT *t, const char *s);
+
+// Add len bytes of s (no NUL among them) to the result being built.
+void basTextAppendLen(BasTextT *t, const char *s, int32_t len);
+
+// Add a row of count cells to the result being built: BAS_ROW_CELL_SEP
+// between them, BAS_ROW_END after, and any tab or line end within a cell
+// made a space, so a cell cannot split the row.
+void basTextRow(BasTextT *t, const char *const *cells, int32_t count);
+
+// The result built: a NUL-terminated copy ("" when out of memory).
+const char *basTextEnd(BasTextT *t);
+
+// s (NULL: "") as the result: basTextStart, basTextAppend, basTextEnd.
+const char *basTextSet(BasTextT *t, const char *s);
+
+// Start a new result (the last one is gone).
+void basTextStart(BasTextT *t);
+
+#endif // DVX_BAS_H
diff --git a/src/libs/kpunch/libdvx/dvxDialog.c b/src/libs/kpunch/libdvx/dvxDialog.c
index 4b7d9bc6..21b01e51 100644
--- a/src/libs/kpunch/libdvx/dvxDialog.c
+++ b/src/libs/kpunch/libdvx/dvxDialog.c
@@ -384,6 +384,7 @@ static bool dialogWindowLive(const AppContextT *ctx, const WindowT *win,
static void drawIconCircle(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, uint32_t color);
static void drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color);
static bool fdAcceptFile(FileDialogStateT *fd, const char *name);
+static void fdAcceptFolder(FileDialogStateT *fd, const char *name);
static void fdAddDrives(FileDialogStateT *fd, WidgetT *row);
static bool fdAddEntry(FileDialogStateT *fd, const char *name, bool isDir);
static int32_t fdDirPartLen(const char *name);
@@ -400,6 +401,7 @@ static void fdOnListClick(WidgetT *w);
static void fdOnListDblClick(WidgetT *w);
static void fdOnOk(WidgetT *w);
static void fdOnPathKey(WidgetT *w, int32_t keyCode, int32_t shift);
+static void fdStartFolder(FileDialogStateT *fd, const char *initialDir, char *rest, int32_t restSize);
static bool fdValidateFilename(FileDialogStateT *fd, const char *name);
static WidgetT *fnAddList(WidgetT *row, const char *label, int32_t width);
static void fnOnChange(WidgetT *w);
@@ -1080,10 +1082,19 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
fd.filters = filters;
fd.filterCount = filterCount;
+ bool folder = (flags & FD_FOLDER) != 0;
+ char rest[DVX_MAX_PATH];
+ const char *defTitle;
+
// Set initial directory. Unknown cwd: resolve accepted names
// relative to '.' rather than the filesystem root. Shown (and
- // returned) with the native separators.
- if (initialDir && initialDir[0]) {
+ // returned) with the native separators. A folder not made yet
+ // starts at the nearest one that exists.
+ rest[0] = '\0';
+
+ if (initialDir && initialDir[0] && folder) {
+ fdStartFolder(&fd, initialDir, rest, sizeof(rest));
+ } else if (initialDir && initialDir[0]) {
snprintf(fd.curDir, sizeof(fd.curDir), "%s", initialDir);
} else if (!getcwd(fd.curDir, sizeof(fd.curDir))) {
strcpy(fd.curDir, ".");
@@ -1092,9 +1103,15 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
platformPathNative(fd.curDir);
// Create dialog window (FD_DIALOG_WIDTH/HEIGHT are the outer size)
+ if (folder) {
+ defTitle = "Choose a Folder";
+ } else {
+ defTitle = (flags & FD_SAVE) ? "Save As" : "Open";
+ }
+
int32_t contentW = FD_DIALOG_WIDTH - CHROME_TOTAL_SIDE * 2;
int32_t contentH = FD_DIALOG_HEIGHT - CHROME_TOTAL_TOP - CHROME_TOTAL_BOTTOM;
- WindowT *win = dialogCreate(ctx, title ? title : ((flags & FD_SAVE) ? "Save As" : "Open"), contentW, contentH, &fd.base);
+ WindowT *win = dialogCreate(ctx, title ? title : defTitle, contentW, contentH, &fd.base);
if (!win) {
return false;
@@ -1131,9 +1148,10 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
fd.base.enterOwners[FD_OWNER_LIST] = fd.fileList;
}
- // Filter row (if filters provided). The dropdown borrows the label
- // array, which lives in the state for the dialog's lifetime.
- if (filters && filterCount > 0) {
+ // Filter row (if filters provided; a folder needs none). The
+ // dropdown borrows the label array, which lives in the state for the
+ // dialog's lifetime.
+ if (filters && filterCount > 0 && !folder) {
WidgetT *filterRow = wgtHBox(root);
if (filterRow) {
@@ -1159,12 +1177,24 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
WidgetT *nameRow = wgtHBox(root);
if (nameRow) {
- wgtLabel(nameRow, "File &name:");
+ wgtLabel(nameRow, folder ? "Folder &name:" : "File &name:");
fd.nameInput = wgtTextInput(nameRow, DVX_MAX_PATH);
+
+ if (fd.nameInput) {
+ wgtSetText(fd.nameInput, rest);
+ }
}
// Button row
- dialogAddOkCancel(root, &fd.base, (flags & FD_SAVE) ? "&Save" : "&Open", fdOnOk, AlignEndE);
+ const char *okLabel;
+
+ if (folder) {
+ okLabel = "&OK";
+ } else {
+ okLabel = (flags & FD_SAVE) ? "&Save" : "&Open";
+ }
+
+ dialogAddOkCancel(root, &fd.base, okLabel, fdOnOk, AlignEndE);
// Load initial directory
fdLoadDir(&fd);
@@ -1499,6 +1529,50 @@ static bool fdAcceptFile(FileDialogStateT *fd, const char *name) {
}
+// A folder dialog's OK: the folder shown (name empty), or name in it, or
+// name itself when it is a full path. It need not exist (the caller
+// makes it), but must not be a file, and a new one's name must be valid
+// where it goes.
+static void fdAcceptFolder(FileDialogStateT *fd, const char *name) {
+ char path[DVX_MAX_PATH];
+ struct stat st;
+
+ // (an absolute name stands alone, an empty one leaves the folder)
+ if (!platformPathJoin(path, sizeof(path), fd->curDir, name)) {
+ dvxErrorBox(fd->ctx, "Invalid Folder", "The path is too long.");
+ return;
+ }
+
+ platformPathTidy(path);
+
+ bool exists = stat(path, &st) == 0;
+
+ if (exists && !S_ISDIR(st.st_mode)) {
+ char msg[FD_MSG_MAX];
+
+ snprintf(msg, sizeof(msg), "%s is a file, not a folder.", path);
+ dvxErrorBox(fd->ctx, "Invalid Folder", msg);
+ return;
+ }
+
+ // A new one: its name must be one its folder can hold
+ char parent[DVX_MAX_PATH];
+
+ if (!exists && platformPathDirName(path, parent, sizeof(parent))) {
+ const char *error = platformValidateFilename(parent, platformPathBaseName(path));
+
+ if (error) {
+ dvxErrorBox(fd->ctx, "Invalid Folder", error);
+ return;
+ }
+ }
+
+ memcpy(fd->outPath, path, sizeof(fd->outPath));
+ fd->base.accepted = true;
+ fd->base.done = true;
+}
+
+
// The drive dropdown at the end of row: every drive platformDriveList
// reports (it never touches the media, so empty floppy and CD-ROM drives
// cost nothing), each shown as its root without the separator ("C:";
@@ -1714,8 +1788,8 @@ static void fdLoadDir(FileDialogStateT *fd) {
bool isDir = S_ISDIR(st.st_mode);
- // Apply filter to files only
- if (!isDir && !platformFilterMatch(ent->d_name, pattern)) {
+ // Apply filter to files only; a folder dialog lists no files
+ if (!isDir && ((fd->flags & FD_FOLDER) || !platformFilterMatch(ent->d_name, pattern))) {
continue;
}
@@ -1889,6 +1963,13 @@ static void fdOnOk(WidgetT *w) {
snprintf(name, sizeof(name), "%s", typed ? typed : "");
+ // A folder dialog: the folder shown, or the one typed (in it, or a
+ // path of its own), made or not
+ if (fd->flags & FD_FOLDER) {
+ fdAcceptFolder(fd, name);
+ return;
+ }
+
// Empty name: if a directory is selected in the list, navigate into
// it. A typed filename always wins over the listbox selection.
if (name[0] == '\0') {
@@ -1997,9 +2078,48 @@ static void fdOnPathKey(WidgetT *w, int32_t keyCode, int32_t shift) {
}
+// A folder dialog's start: initialDir, or (not made yet) the nearest
+// folder above it that exists, with the rest of it in rest for the name
+// box.
+static void fdStartFolder(FileDialogStateT *fd, const char *initialDir, char *rest, int32_t restSize) {
+ struct stat st;
+
+ snprintf(fd->curDir, sizeof(fd->curDir), "%s", initialDir);
+ platformPathTidy(fd->curDir);
+
+ while (stat(fd->curDir, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ char parent[DVX_MAX_PATH];
+
+ if (!platformPathDirName(fd->curDir, parent, sizeof(parent)) || platformPathSame(parent, fd->curDir)) {
+ break;
+ }
+
+ snprintf(fd->curDir, sizeof(fd->curDir), "%s", parent);
+ }
+
+ // What lies past the folder found ("." holds a relative initialDir
+ // whole)
+ char start[DVX_MAX_PATH];
+ const char *tail = start;
+
+ snprintf(start, sizeof(start), "%s", initialDir);
+ platformPathTidy(start);
+
+ if (strcmp(fd->curDir, ".") != 0 || platformPathIsAbsolute(start)) {
+ tail = platformPathIsUnder(start, fd->curDir);
+ tail = tail ? tail : "";
+ }
+
+ while (platformPathIsSep(*tail)) {
+ tail++;
+ }
+
+ snprintf(rest, restSize, "%s", strcmp(tail, ".") == 0 ? "" : tail);
+}
+
+
// Check the name by the rules of the folder's disk (long names where it
// takes them).
-
static bool fdValidateFilename(FileDialogStateT *fd, const char *name) {
const char *error = platformValidateFilename(fd->curDir, name);
diff --git a/src/libs/kpunch/libdvx/dvxDlg.h b/src/libs/kpunch/libdvx/dvxDlg.h
index 05328214..e9a772bd 100644
--- a/src/libs/kpunch/libdvx/dvxDlg.h
+++ b/src/libs/kpunch/libdvx/dvxDlg.h
@@ -98,6 +98,7 @@ int32_t dvxInfoBox(AppContextT *ctx, const char *title, const char *message);
#define FD_OPEN 0x0000 // Open file (default)
#define FD_SAVE 0x0001 // Save file
+#define FD_FOLDER 0x0002 // Choose a folder (new or existing)
// ============================================================
// File dialog filter
@@ -123,6 +124,13 @@ typedef struct {
// via dvxUpdate() loop. Returns true if the user selected a file (path
// written to outPath), false if cancelled or closed. initialDir may be
// NULL to start in the current working directory.
+//
+// FD_FOLDER chooses a folder instead: the list shows folders only (the
+// filters are not used), OK returns the folder shown, or the folder of
+// that name in it when one is typed -- an existing one or a new one,
+// which the caller makes. An initialDir that does not exist yet (the
+// folder a program will make) opens at its nearest existing folder with
+// the rest typed in already, so OK alone returns it unchanged.
bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const char *initialDir, const FileFilterT *filters, int32_t filterCount, char *outPath, int32_t outPathSize);
// Display a modal input box with a prompt, text input field, and
diff --git a/src/libs/kpunch/libdvx/dvxPrefs.c b/src/libs/kpunch/libdvx/dvxPrefs.c
index d026fc0a..60609d3a 100644
--- a/src/libs/kpunch/libdvx/dvxPrefs.c
+++ b/src/libs/kpunch/libdvx/dvxPrefs.c
@@ -37,6 +37,7 @@
#include
#include
#include
+#include
#include "dvxMem.h"
#include "thirdparty/stb_ds_wrap.h"
@@ -84,6 +85,19 @@ typedef struct {
// Decimal text of an int32_t including sign and NUL
#define PREFS_INT_TEXT_MAX 16
+// A secret (prefsSetSecret): PREFS_SECRET_MARK, the salt as hex, then each
+// byte of the text XORed with the next byte of a keystream started from
+// the salt, as hex. Obfuscation, not encryption.
+#define PREFS_SECRET_MARK '*'
+#define PREFS_SECRET_SALT_BYTES 4
+#define PREFS_SECRET_SEED 0x9E3779B9u // golden ratio: spreads the call count into the salt, and starts the keystream
+#define PREFS_SECRET_BYTE_SHIFT 24 // a keystream byte: the state's top byte
+#define PREFS_XORSHIFT_A 13 // xorshift32's shift triple (Marsaglia)
+#define PREFS_XORSHIFT_B 17
+#define PREFS_XORSHIFT_C 5
+#define PREFS_HEX_DIGITS 2 // per byte
+#define PREFS_BITS_PER_BYTE 8
+
// Line ending written by prefsSaveAs
#define PREFS_EOL "\r\n"
@@ -95,10 +109,13 @@ typedef struct {
// Words prefsGetBool accepts (case-insensitive, whole value)
static const char *const sTrueWords[] = { "true", "yes", "1" };
+static const char *const sFalseWords[] = { "false", "no", "0" };
// Settings files held in memory (stb_ds array)
static PrefsMemFileT *sMemFiles = NULL;
-static const char *const sFalseWords[] = { "false", "no", "0" };
+
+// prefsSetSecret calls so far: no two in one run share a salt
+static uint32_t sSecretCalls = 0;
// ============================================================
@@ -138,9 +155,10 @@ static bool matchWord(const char *val, const char *const *words, int32_t cou
static char *memFileCopy(const char *path, int32_t *len);
static bool memFileStore(const char *path, const char *text, int32_t len);
static void parseLine(PrefsHandleT *h, char *line, char **currentSection);
+static uint8_t secretByte(uint32_t *state);
+static uint32_t secretStart(uint32_t salt);
static char *strdupOrNull(const char *s);
static char *trimInPlace(char *buf);
-static bool writeText(const char *filename, const char *text, int32_t len);
// Appends one entry; every string is copied (NULL stays NULL). Out of
@@ -438,6 +456,46 @@ int32_t prefsGetInt(PrefsHandleT *h, const char *section, const char *key, int32
}
+void prefsGetSecret(PrefsHandleT *h, const char *section, const char *key, char *out, int32_t outSize) {
+ const char *value = prefsGetString(h, section, key, "");
+ size_t len = value ? strlen(value) : 0;
+ uint32_t salt = 0;
+ uint32_t state;
+ int32_t n = 0;
+ unsigned byte;
+
+ if (outSize <= 0) {
+ return;
+ }
+
+ out[0] = '\0';
+
+ if (!value) {
+ return;
+ }
+
+ // Not one prefsSetSecret wrote (typed in by hand): as it is
+ if (value[0] != PREFS_SECRET_MARK || len < 1 + PREFS_SECRET_SALT_BYTES * PREFS_HEX_DIGITS || (len - 1) % PREFS_HEX_DIGITS != 0 || strspn(value + 1, "0123456789abcdefABCDEF") != len - 1) {
+ snprintf(out, (size_t)outSize, "%s", value);
+ return;
+ }
+
+ for (int32_t i = 0; i < PREFS_SECRET_SALT_BYTES; i++) {
+ sscanf(value + 1 + i * PREFS_HEX_DIGITS, "%2x", &byte);
+ salt = (salt << PREFS_BITS_PER_BYTE) | byte;
+ }
+
+ state = secretStart(salt);
+
+ for (const char *p = value + 1 + PREFS_SECRET_SALT_BYTES * PREFS_HEX_DIGITS; *p && n < outSize - 1; p += PREFS_HEX_DIGITS) {
+ sscanf(p, "%2x", &byte);
+ out[n++] = (char)(byte ^ secretByte(&state));
+ }
+
+ out[n] = '\0';
+}
+
+
const char *prefsGetString(PrefsHandleT *h, const char *section, const char *key, const char *defaultVal) {
if (!h || !section || !key) {
return defaultVal;
@@ -567,9 +625,9 @@ bool prefsSave(PrefsHandleT *h) {
}
-// Writes the file (see writeText). When DVX's folder cannot be written
-// to (read-only media) and the write fails, the text is kept in memory
-// instead and prefsLoad reads it back until DVX exits.
+// Writes the file all or nothing (platformWriteFile). When DVX's folder
+// cannot be written to (read-only media) and the write fails, the text is
+// kept in memory instead and prefsLoad reads it back until DVX exits.
bool prefsSaveAs(PrefsHandleT *h, const char *filename) {
if (!h || !filename) {
return false;
@@ -577,7 +635,7 @@ bool prefsSaveAs(PrefsHandleT *h, const char *filename) {
char *text = formatText(h);
int32_t len = (int32_t)arrlen(text);
- bool ok = writeText(filename, text ? text : "", len);
+ bool ok = platformWriteFile(filename, text ? text : "", len);
if (!ok && platformIsReadOnly()) {
ok = memFileStore(filename, text ? text : "", len);
@@ -600,6 +658,41 @@ void prefsSetInt(PrefsHandleT *h, const char *section, const char *key, int32_t
}
+void prefsSetSecret(PrefsHandleT *h, const char *section, const char *key, const char *value) {
+ size_t len = value ? strlen(value) : 0;
+ char *text;
+ char *p;
+ uint32_t salt;
+ uint32_t state;
+
+ if (len == 0) {
+ prefsSetString(h, section, key, "");
+ return;
+ }
+
+ text = (char *)malloc(1 + (PREFS_SECRET_SALT_BYTES + len) * PREFS_HEX_DIGITS + 1);
+
+ if (!text) {
+ return;
+ }
+
+ // A salt of its own each time, so the same text is not the same digits twice
+ sSecretCalls++;
+ salt = (uint32_t)clock() ^ (uint32_t)time(NULL) ^ (sSecretCalls * PREFS_SECRET_SEED);
+ state = secretStart(salt);
+ p = text;
+ *p++ = PREFS_SECRET_MARK;
+ p += sprintf(p, "%0*lx", PREFS_SECRET_SALT_BYTES * PREFS_HEX_DIGITS, (unsigned long)salt);
+
+ for (size_t i = 0; i < len; i++) {
+ p += sprintf(p, "%0*x", PREFS_HEX_DIGITS, (unsigned)((uint8_t)value[i] ^ secretByte(&state)));
+ }
+
+ prefsSetString(h, section, key, text);
+ free(text);
+}
+
+
void prefsSetString(PrefsHandleT *h, const char *section, const char *key, const char *value) {
if (!h || !section || !key) {
return;
@@ -689,6 +782,26 @@ void prefsSetVideoMode(PrefsHandleT *h, int32_t width, int32_t height, int32_t b
}
+// The next keystream byte for a secret (xorshift32 on state).
+static uint8_t secretByte(uint32_t *state) {
+ uint32_t x = *state;
+
+ x ^= x << PREFS_XORSHIFT_A;
+ x ^= x >> PREFS_XORSHIFT_B;
+ x ^= x << PREFS_XORSHIFT_C;
+ *state = x;
+ return (uint8_t)(x >> PREFS_SECRET_BYTE_SHIFT);
+}
+
+
+// A secret's keystream state from its salt (xorshift32 must not start at 0).
+static uint32_t secretStart(uint32_t salt) {
+ uint32_t state = salt ^ PREFS_SECRET_SEED;
+
+ return state ? state : PREFS_SECRET_SEED;
+}
+
+
static char *strdupOrNull(const char *s) {
return s ? strdup(s) : NULL;
}
@@ -699,35 +812,3 @@ static char *trimInPlace(char *buf) {
dvxTrimRight(p);
return p;
}
-
-
-// Writes text to the .$ sibling (PLATFORM_TMP_EXT; the
-// extension is replaced, so 8.3 names stay valid) and swaps it in only
-// when every write succeeded, so a full disk or I/O error leaves the
-// previous file intact and reports failure.
-static bool writeText(const char *filename, const char *text, int32_t len) {
- char tmpPath[DVX_MAX_PATH];
- char bakPath[DVX_MAX_PATH];
-
- if (!platformPathSibling(tmpPath, sizeof(tmpPath), filename, PLATFORM_TMP_EXT) || !platformPathSibling(bakPath, sizeof(bakPath), filename, PLATFORM_BAK_EXT)) {
- return false;
- }
-
- FILE *fp = fopen(tmpPath, "wb");
-
- if (!fp) {
- return false;
- }
-
- bool ok = (int32_t)fwrite(text, 1, (size_t)len, fp) == len;
-
- // fclose flushes: a late write error surfaces here
- ok = (fclose(fp) == 0) && ok;
-
- if (!ok) {
- remove(tmpPath);
- return false;
- }
-
- return platformReplaceFile(tmpPath, filename, bakPath);
-}
diff --git a/src/libs/kpunch/libdvx/dvxPrefs.h b/src/libs/kpunch/libdvx/dvxPrefs.h
index 4c16f01d..5ece74fc 100644
--- a/src/libs/kpunch/libdvx/dvxPrefs.h
+++ b/src/libs/kpunch/libdvx/dvxPrefs.h
@@ -88,6 +88,11 @@ const char *prefsGetString(PrefsHandleT *h, const char *section, const char *key
// Retrieve an integer value.
int32_t prefsGetInt(PrefsHandleT *h, const char *section, const char *key, int32_t defaultVal);
+// A secret (a password) prefsSetSecret kept in key of [section], into out
+// (outSize bytes; "" when the key is missing). A value it did not write
+// (typed into the file by hand) is taken as it is.
+void prefsGetSecret(PrefsHandleT *h, const char *section, const char *key, char *out, int32_t outSize);
+
// The [video] display mode DVX starts in (width, height, bits per pixel);
// DVX_DEFAULT_VIDEO_* for missing keys.
void prefsGetVideoMode(PrefsHandleT *h, int32_t *width, int32_t *height, int32_t *bpp);
@@ -107,6 +112,12 @@ void prefsSetString(PrefsHandleT *h, const char *section, const char *key, const
// Set an integer value.
void prefsSetInt(PrefsHandleT *h, const char *section, const char *key, int32_t value);
+// Set a secret (a password), obfuscated so it cannot be read at a glance:
+// "*", a salt, and the text mixed with a keystream from it, all as hex
+// digits (a new salt each time). Not encryption: DVX reads it back, and
+// so could anyone with DVX's code. "" is stored as "".
+void prefsSetSecret(PrefsHandleT *h, const char *section, const char *key, const char *value);
+
// Set a boolean value (stored as "true"/"false").
void prefsSetBool(PrefsHandleT *h, const char *section, const char *key, bool value);
diff --git a/src/libs/kpunch/libdvx/dvxPrefsBas.c b/src/libs/kpunch/libdvx/dvxPrefsBas.c
index 2d254125..cd1bf6da 100644
--- a/src/libs/kpunch/libdvx/dvxPrefsBas.c
+++ b/src/libs/kpunch/libdvx/dvxPrefsBas.c
@@ -37,21 +37,17 @@
// whatever a program leaves open is closed (unsaved) when it ends.
#include "dvxPrefs.h"
-#include "dvxMem.h"
#include "dvxTypes.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
-#include
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
-#define SHELL_CLEANUP_SYM "shellRegisterAppCleanup"
#define CHOICE_NAME_SEP '|' // between the names of PrefsGetChoiceFrom's list
+#define SECRET_TEXT_MAX 256 // the longest secret PrefsGetSecret$ gives back
// The choice tables BASIC can read (prefs.bas PREFS_CHOICE_*: the
// binding's own numbers, which must not change)
@@ -68,12 +64,6 @@ typedef struct {
const char *defaultName;
} BasChoiceTableT;
-// One open handle and the program it belongs to
-typedef struct {
- PrefsHandleT *prefs; // NULL: the handle is free
- int32_t appId;
-} BasPrefsT;
-
// The [video] mode of a handle (prefsGetVideoMode)
typedef struct {
int32_t width;
@@ -81,14 +71,6 @@ typedef struct {
int32_t bpp;
} BasVideoModeT;
-// The shell's app-cleanup registry (shellApp.h), looked up at run time:
-// this library loads before the shell, and the host tests may have none.
-typedef void (*BasCleanupFnT)(int32_t appId);
-typedef void (*BasCleanupRegFnT)(BasCleanupFnT fn);
-
-static BasPrefsT *sPrefs = NULL; // stb_ds; handle = index + 1
-static bool sCleanupHooked = false;
-
static const BasChoiceTableT sChoiceTables[BasChoiceCountE] = {
[BasChoiceAccelE] = { prefsAccelChoices, PREFS_ACCEL_CHOICE_COUNT, MOUSE_ACCEL_DEFAULT },
[BasChoiceWallpaperE] = { prefsWallpaperChoices, PREFS_WALLPAPER_CHOICE_COUNT, WALLPAPER_MODE_DEFAULT },
@@ -99,7 +81,6 @@ static const BasChoiceTableT sChoiceTables[BasChoiceCountE] = {
// Prototypes
// ============================================================
-static void appEnded(int32_t appId);
static const PrefsChoiceT *choiceOf(int32_t table, int32_t index);
static const BasChoiceTableT *choiceTable(int32_t table);
static int32_t handleNew(PrefsHandleT *prefs);
@@ -114,11 +95,13 @@ int32_t PrefsChoiceValue(int32_t table, int32_t index);
void PrefsClose(int32_t handle);
int32_t PrefsCreate(void);
const char *PrefsDvxIni(void);
+static void prefsFree(void *obj);
const char *PrefsGet(int32_t handle, const char *section, const char *key, const char *defaultValue);
int32_t PrefsGetBool(int32_t handle, const char *section, const char *key, int32_t defaultValue);
int32_t PrefsGetChoice(int32_t handle, const char *section, const char *key, int32_t table);
int32_t PrefsGetChoiceFrom(int32_t handle, const char *section, const char *key, const char *names, const char *defaultName);
int32_t PrefsGetInt(int32_t handle, const char *section, const char *key, int32_t defaultValue);
+const char *PrefsGetSecret(int32_t handle, const char *section, const char *key);
const char *PrefsKeyName(int32_t handle, const char *section, int32_t index);
int32_t PrefsLoad(const char *path);
void PrefsRemove(int32_t handle, const char *section, const char *key);
@@ -127,21 +110,18 @@ int32_t PrefsSaveAs(int32_t handle, const char *path);
void PrefsSet(int32_t handle, const char *section, const char *key, const char *value);
void PrefsSetBool(int32_t handle, const char *section, const char *key, int32_t value);
void PrefsSetInt(int32_t handle, const char *section, const char *key, int32_t value);
+void PrefsSetSecret(int32_t handle, const char *section, const char *key, const char *value);
void PrefsSetVideoMode(int32_t handle, int32_t width, int32_t height, int32_t bpp);
int32_t PrefsVideoBpp(int32_t handle);
int32_t PrefsVideoHeight(int32_t handle);
int32_t PrefsVideoWidth(int32_t handle);
static BasVideoModeT videoModeOf(int32_t handle);
+// ============================================================
+// Module state
+// ============================================================
-// Shell app-cleanup hook: close what a program that ended left open.
-static void appEnded(int32_t appId) {
- for (int32_t i = 0; i < (int32_t)arrlen(sPrefs); i++) {
- if (sPrefs[i].prefs && sPrefs[i].appId == appId) {
- PrefsClose(i + 1);
- }
- }
-}
+static BasHandlesT sPrefs = BAS_HANDLES(prefsFree, 0); // the open settings
// Entry index of choice table table, or NULL when either is out of range.
@@ -161,45 +141,19 @@ static const BasChoiceTableT *choiceTable(int32_t table) {
// A handle for prefs, owned by the running program (prefs is closed when
// no handle can be had).
static int32_t handleNew(PrefsHandleT *prefs) {
- if (!prefs) {
- return BAS_NO_HANDLE;
+ int32_t handle = basHandleAdd(&sPrefs, prefs);
+
+ if (prefs && handle == BAS_NO_HANDLE) {
+ prefsClose(prefs);
}
- if (!sCleanupHooked) {
- BasCleanupRegFnT reg = (BasCleanupRegFnT)dlsym(NULL, DVX_SYM(SHELL_CLEANUP_SYM));
-
- sCleanupHooked = true;
-
- if (reg) {
- reg(appEnded);
- }
- }
-
- int32_t idx = 0;
-
- while (idx < (int32_t)arrlen(sPrefs) && sPrefs[idx].prefs) {
- idx++;
- }
-
- if (idx == (int32_t)arrlen(sPrefs)) {
- BasPrefsT blank = { NULL, 0 };
-
- arrput(sPrefs, blank);
- }
-
- sPrefs[idx].prefs = prefs;
- sPrefs[idx].appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
- return idx + 1;
+ return handle;
}
// The settings behind a handle, or NULL (dvxPrefs.h).
PrefsHandleT *prefsBasHandle(int32_t handle) {
- if (handle <= BAS_NO_HANDLE || handle > (int32_t)arrlen(sPrefs)) {
- return NULL;
- }
-
- return sPrefs[handle - 1].prefs;
+ return (PrefsHandleT *)basHandleGet(&sPrefs, handle);
}
@@ -262,12 +216,7 @@ int32_t PrefsChoiceValue(int32_t table, int32_t index) {
// Free the handle; changes not saved are lost.
void PrefsClose(int32_t handle) {
- PrefsHandleT *prefs = prefsBasHandle(handle);
-
- if (prefs) {
- prefsClose(prefs);
- sPrefs[handle - 1].prefs = NULL;
- }
+ basHandleFree(&sPrefs, handle);
}
@@ -283,6 +232,13 @@ const char *PrefsDvxIni(void) {
}
+// A handle's settings (the handles' freeFn): closed, changes not saved
+// lost.
+static void prefsFree(void *obj) {
+ prefsClose((PrefsHandleT *)obj);
+}
+
+
// The text of key in [section], or defaultValue.
const char *PrefsGet(int32_t handle, const char *section, const char *key, const char *defaultValue) {
PrefsHandleT *prefs = prefsBasHandle(handle);
@@ -357,6 +313,22 @@ int32_t PrefsGetInt(int32_t handle, const char *section, const char *key, int32_
}
+// A secret (a password) PrefsSetSecret kept in key of [section] ("" when
+// missing; a value typed into the file by hand as it is).
+const char *PrefsGetSecret(int32_t handle, const char *section, const char *key) {
+ static char text[SECRET_TEXT_MAX];
+ PrefsHandleT *prefs = prefsBasHandle(handle);
+
+ text[0] = '\0';
+
+ if (prefs) {
+ prefsGetSecret(prefs, section, key, text, sizeof(text));
+ }
+
+ return text;
+}
+
+
// Name of the index-th key (0-based, file order) of [section], "" past
// the last one.
const char *PrefsKeyName(int32_t handle, const char *section, int32_t index) {
@@ -427,6 +399,16 @@ void PrefsSetInt(int32_t handle, const char *section, const char *key, int32_t v
}
+// Set key in [section] to a secret (a password), obfuscated.
+void PrefsSetSecret(int32_t handle, const char *section, const char *key, const char *value) {
+ PrefsHandleT *prefs = prefsBasHandle(handle);
+
+ if (prefs) {
+ prefsSetSecret(prefs, section, key, value);
+ }
+}
+
+
// The [video] mode DVX starts in.
void PrefsSetVideoMode(int32_t handle, int32_t width, int32_t height, int32_t bpp) {
PrefsHandleT *prefs = prefsBasHandle(handle);
diff --git a/src/libs/kpunch/libdvx/dvxTextBas.c b/src/libs/kpunch/libdvx/dvxTextBas.c
index 9c31543b..d7b57237 100644
--- a/src/libs/kpunch/libdvx/dvxTextBas.c
+++ b/src/libs/kpunch/libdvx/dvxTextBas.c
@@ -34,13 +34,11 @@
#include "dvxDraw.h"
#include "dvxFontFmt.h"
#include "dvxMem.h"
+#include "dvxBas.h"
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-
// What a measuring call reads from the font
typedef enum {
FontMetricAscentE,
diff --git a/src/libs/kpunch/libdvx/dvxTypes.h b/src/libs/kpunch/libdvx/dvxTypes.h
index 6bd52560..118e137e 100644
--- a/src/libs/kpunch/libdvx/dvxTypes.h
+++ b/src/libs/kpunch/libdvx/dvxTypes.h
@@ -67,6 +67,15 @@
// bump, and when the table was not regenerated after one.
#define DVX_ABI_VERSION 10
+// The DVX release: the one place its version is written (Program
+// Manager's About box, Setup's record of DVX as a package, and the
+// packages that need at least this DVX). DVX_VERSION_TEXT is
+// "major.minor.patch".
+#define DVX_VERSION_MAJOR 1
+#define DVX_VERSION_MINOR 0
+#define DVX_VERSION_PATCH 0
+#define DVX_VERSION_TEXT DVX_ABI_STR(DVX_VERSION_MAJOR) "." DVX_ABI_STR(DVX_VERSION_MINOR) "." DVX_ABI_STR(DVX_VERSION_PATCH)
+
// A module's stamp is named , e.g. the
// stamp of LIBS/KPUNCH/LIBDVX/LIBDVX.LIB is libdvxAbiVersion.
#define DVX_ABI_SUFFIX AbiVersion
@@ -246,6 +255,7 @@ typedef struct {
// drawTextStyled. Bold and italic are faces; underline and strike-
// through are lines every text call draws with a font that has them
// (as in VB, where they are part of the font).
+#define FONT_STYLE_REGULAR 0x0000 // none of the styles
#define FONT_STYLE_BOLD 0x0001
#define FONT_STYLE_ITALIC 0x0002
#define FONT_STYLE_UNDERLINE 0x0004
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlat.h b/src/libs/kpunch/libdvx/platform/dvxPlat.h
index 93cfe5ea..ef94e151 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlat.h
+++ b/src/libs/kpunch/libdvx/platform/dvxPlat.h
@@ -129,7 +129,8 @@ const char *platformTempDir(void);
// only reads: SYSTEM (DVX's tools and the shipped DVX.INI and ASSOC.INI),
// LIBS, WIDGETS, APPS (each program, its help, and the DATA folder of the
// files it ships), and SHARED (the fonts, sounds, themes, and wallpapers
-// DVX ships for every program; only DVX's own package puts files there).
+// for every program; only packages put files there: DVX's own, and
+// add-ons in a folder of their vendor's).
// Everything written while DVX runs goes in the user folder: USER in the
// DVX folder, or the folder the DVXUSER environment variable names. It
// holds DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the
@@ -138,6 +139,8 @@ const char *platformTempDir(void);
// files it saves), and DOWNLOAD.
#define DVX_SYSTEM_DIR "SYSTEM"
+#define DVX_LIBS_DIR "LIBS"
+#define DVX_WIDGETS_DIR "WIDGETS"
#define DVX_APPS_DIR "APPS"
#define DVX_SHARED_DIR "SHARED"
#define DVX_USER_DIR "USER"
@@ -149,6 +152,12 @@ const char *platformTempDir(void);
#define DVX_SOUNDS_DIR "SOUNDS"
#define DVX_THEMES_DIR "THEMES"
#define DVX_WPAPER_DIR "WPAPER"
+#define DVX_BANKS_DIR "BANKS" // MIDI instrument banks (OP2), in SHARED
+#define DVX_VENDOR_DIR "KPUNCH" // DVX's own vendor folder (APPS\KPUNCH, LIBS\KPUNCH, ...); add-ons have their own
+
+// DVX /SAFE (or Shift held while it starts): a safe start, in safe video
+// mode and without add-on libraries and widgets (the loader and the shell)
+#define DVX_SWITCH_SAFE "/SAFE"
// The kinds of file SHARED and the user folder both have a folder of
typedef enum {
@@ -156,6 +165,7 @@ typedef enum {
DvxKindSoundsE,
DvxKindThemesE,
DvxKindWpaperE,
+ DvxKindBanksE,
DvxKindCountE
} DvxKindE;
@@ -189,8 +199,8 @@ const char *platformDvxIniPath(void);
// extension>. Not made here.
bool platformAppUserDir(char *out, int32_t outSize, const char *appPath);
-// A kind's folder name ("FONTS", "SOUNDS", "THEMES", "WPAPER"); NULL for
-// a kind out of range.
+// A kind's folder name ("FONTS", "SOUNDS", "THEMES", "WPAPER", "BANKS");
+// NULL for a kind out of range.
const char *platformKindName(DvxKindE kind);
// out = SHARED\ (user false) or \ (user true).
@@ -201,20 +211,35 @@ bool platformKindDir(char *out, int32_t outSize, DvxKindE kind, bool user);
bool platformKindBrowseDir(char *out, int32_t outSize, DvxKindE kind);
// Find the file name of a kind: the user's first, then SHARED's. A name
-// with a folder in it is a path of its own, taken as it is. False when
-// no such file exists.
+// may be VENDOR\NAME (an add-on's file, in its vendor's folder of the
+// kind). Any other name with a folder in it (and an absolute one) is a
+// path of its own, taken as it is. False when no such file exists.
bool platformKindFind(char *out, int32_t outSize, DvxKindE kind, const char *name);
-// The other way: when path is a file directly in one of a kind's folders
-// (the user's or SHARED's), its name alone -- what settings store, so
-// platformKindFind finds it again; false for any other path.
+// The other way: when path is a file in one of a kind's folders (the
+// user's or SHARED's), directly or in a vendor folder there, its name
+// (NAME or VENDOR\NAME) -- what settings store, so platformKindFind finds
+// it again; false for any other path.
bool platformKindNameOf(DvxKindE kind, const char *path, char *out, int32_t outSize);
// The file names in a kind's folders: the user's (sorted, case ignored),
-// then SHARED's not among them (sorted). An stb_ds array of malloc'd
-// names as dvxReadDir gives (never NULL; free with dvxReadDirFree).
+// then SHARED's not among them (sorted). Files in a folder of the kind's
+// folder (an add-on vendor's) come as VENDOR\NAME. An stb_ds array of
+// malloc'd names as dvxReadDir gives (never NULL; free with
+// dvxReadDirFree).
char **platformKindReadDir(DvxKindE kind);
+// Notes the loader leaves for the shell to show once the desktop is up
+// (an add-on that was not loaded, and why); each text is copied. They
+// hold PLATFORM_BOOT_NOTES_MAX bytes in all (the NUL counted); past that a
+// "..." line says there were more.
+#define PLATFORM_BOOT_NOTES_MAX 2048
+
+void platformBootNoteAdd(const char *text);
+
+// The notes so far, one a line ("" when there are none).
+const char *platformBootNotes(void);
+
// ============================================================
// Timing
// ============================================================
@@ -791,6 +816,12 @@ bool platformPathSibling(char *out, int32_t outSize, const char *path, const cha
// deleted, and prefsLoad falls back to reading it.
bool platformReplaceFile(const char *tmpPath, const char *path, const char *bakPath);
+// Write len bytes of data as the whole of path, all or nothing: into the
+// PLATFORM_TMP_EXT sibling, then swapped in (platformReplaceFile), so a
+// full disk or an I/O error leaves the file as it was. False when not
+// done.
+bool platformWriteFile(const char *path, const void *data, int32_t len);
+
// Return a pointer to the leaf (basename) portion of path: after the last
// separator, or after a drive ("C:A.TXT" -> "A.TXT"). Never returns
// NULL -- with neither, the whole path is the basename.
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatInt.h b/src/libs/kpunch/libdvx/platform/dvxPlatInt.h
new file mode 100644
index 00000000..c8427d32
--- /dev/null
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatInt.h
@@ -0,0 +1,50 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// dvxPlatInt.h -- what the platform layer's own files share
+//
+// Private to the platform layer (not in the SDK): the DOS interrupt and
+// real-mode facts dvxPlatformDos.c and dvxPlatformUtil.c both use, and
+// the host's root and current folder (dvxPlatformHost.c and
+// dvxPlatformUtil.c).
+
+#ifndef DVX_PLAT_INT_H
+#define DVX_PLAT_INT_H
+
+// Real-mode interrupt: DOS services
+#define INT_DOS 0x21
+
+// FLAGS bits returned in __dpmi_regs.x.flags
+#define CPU_FLAG_CF 0x0001
+#define CPU_FLAG_ZF 0x0040
+
+// A linear address as a real-mode segment and offset
+#define REAL_MODE_SEG_SHIFT 4
+#define REAL_MODE_OFF_MASK 0x0F
+
+// The host's one drive (platformDriveList), and where platformDiskSpace
+// stops looking for an existing folder
+#define HOST_ROOT "/"
+#define HOST_CURRENT_DIR "."
+
+#endif // DVX_PLAT_INT_H
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
index 13508d92..815c82ba 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
@@ -52,6 +52,7 @@
// of installing a real-mode callback for mouse events.
#include "dvxPlat.h"
+#include "dvxPlatInt.h"
#include "dvxPlatIsr.h"
#include "dvxPal.h"
#include "dvxPrefs.h"
@@ -139,13 +140,8 @@
// Real-mode interrupt vectors
#define INT_VIDEO 0x10
#define INT_KEYBOARD 0x16
-#define INT_DOS 0x21
#define INT_MOUSE 0x33
-// FLAGS bits returned in __dpmi_regs.x.flags
-#define CPU_FLAG_CF 0x0001
-#define CPU_FLAG_ZF 0x0040
-
// EFLAGS ID bit: toggleable only when CPUID exists
#define EFLAGS_ID_BIT 0x200000U
@@ -198,8 +194,6 @@
// Real-mode far pointer layout: offset word, then segment word
#define FAR_PTR_SEG_OFF 2
-#define REAL_MODE_SEG_SHIFT 4
-#define REAL_MODE_OFF_MASK 0x0F
// VBE mode info block offsets (4F01h)
#define VBE_MI_ATTR_OFF 0
@@ -284,26 +278,14 @@
#define DOS_FN_SET_BREAK 0x3301
#define DOS_BREAK_OFF 0x0000
#define DOS_FN_GET_BOOT_DRIVE 0x3305 // DL = boot drive (1 = A:)
-#define DOS_FN_DISK_FREE 0x3600
-#define DOS_FN_EXT_DISK_FREE 0x7303 // FAT32 DOS (7.1, FreeDOS): free space of the root at DS:DX into ES:DI, CX bytes
#define DOS_FN_LFN_TRUENAME 0x7160 // long-name truename of DS:SI into ES:DI, CL = form
#define LFN_TRUENAME_SHORT 1 // 7160h CL: the short (8.3) form
#define DOS_FN_IOCTL_REMOVABLE 0x4408
#define DOS_FN_IOCTL_REMOTE 0x4409
#define DOS_FN_IOCTL_DRIVE_MAP 0x440E // AL = letter the drive answers to now (0 = only one)
-#define DOS_DISK_INVALID 0xFFFF
#define DOS_DRIVE_REMOTE_BIT 0x1000 // 4409h DX bit 12: remote (network / CD-ROM)
#define DOS_REMOVABLE_MEDIA 0 // 4408h AX: removable
-#define DOS_DEFAULT_DRIVE 0 // 36h DL: the current drive
-// The 7303h extended free-space record (its version word at +2 goes in as
-// 0) and where it sits in the transfer buffer, after the "X:\" root
-#define EXT_FREE_SIZE 0x2C
-#define EXT_FREE_SEC_PER_CLUS 0x04
-#define EXT_FREE_BYTES_PER_SEC 0x08
-#define EXT_FREE_AVAIL_CLUS 0x0C
-#define EXT_FREE_TOTAL_CLUS 0x10
-#define EXT_FREE_ROOT_LEN 4
#define DOS_VERSION_BYTE_SHIFT 8
// DOS critical-error handler: the real-mode vector and a stub in DOS
@@ -477,7 +459,6 @@ static uint8_t biosVideoMode(void);
static inline void copySpan(uint8_t *dst, const uint8_t *src, uint32_t bytes);
static bool cpuHasTsc(void);
static uint32_t estimateClockMhz(void);
-static bool extDiskSpace(int32_t drive, uint64_t *totalBytes, uint64_t *freeBytes);
static uint32_t farPtrToLinear(uint32_t addr);
static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, ModeScoreFnT score, uint16_t *outMode, VbeModeT *outInfo, DisplayT *d);
static bool hasCpuid(void);
@@ -803,43 +784,6 @@ static uint32_t estimateClockMhz(void) {
}
-// INT 21h AX=7303h, the FAT32 free-space call: 32-bit cluster counts, so
-// a disk past 2 GB (which AH=36h caps) reports its true size. drive is
-// 1-based (0 = the current one). False where DOS lacks the call (it
-// leaves the record zero) or the drive does not answer it.
-static bool extDiskSpace(int32_t drive, uint64_t *totalBytes, uint64_t *freeBytes) {
- static const uint8_t zero[EXT_FREE_SIZE];
- char root[EXT_FREE_ROOT_LEN] = "A:" DVX_PATH_SEP;
- uint32_t rec = __tb + EXT_FREE_ROOT_LEN;
- __dpmi_regs r;
-
- root[0] = (char)('A' + ((drive == DOS_DEFAULT_DRIVE) ? getdisk() : drive - 1));
- dosmemput(root, sizeof(root), __tb);
- dosmemput(zero, sizeof(zero), rec);
- memset(&r, 0, sizeof(r));
- r.x.ax = DOS_FN_EXT_DISK_FREE;
- r.x.ds = (uint16_t)(__tb >> REAL_MODE_SEG_SHIFT);
- r.x.dx = (uint16_t)(__tb & REAL_MODE_OFF_MASK);
- r.x.es = (uint16_t)(rec >> REAL_MODE_SEG_SHIFT);
- r.x.di = (uint16_t)(rec & REAL_MODE_OFF_MASK);
- r.x.cx = EXT_FREE_SIZE;
- __dpmi_int(INT_DOS, &r);
-
- uint32_t secPerClus = _farpeekl(_dos_ds, rec + EXT_FREE_SEC_PER_CLUS);
- uint32_t bytesPerSec = _farpeekl(_dos_ds, rec + EXT_FREE_BYTES_PER_SEC);
-
- if ((r.x.flags & CPU_FLAG_CF) || secPerClus == 0 || bytesPerSec == 0) {
- return false;
- }
-
- uint64_t clusterSize = (uint64_t)secPerClus * bytesPerSec;
-
- *totalBytes = clusterSize * _farpeekl(_dos_ds, rec + EXT_FREE_TOTAL_CLUS);
- *freeBytes = clusterSize * _farpeekl(_dos_ds, rec + EXT_FREE_AVAIL_CLUS);
- return true;
-}
-
-
// Convert a real-mode far pointer stored at linear address addr (offset
// word, then segment word) into a linear address.
static uint32_t farPtrToLinear(uint32_t addr) {
@@ -1295,36 +1239,6 @@ static void platformCrashHandler(int sig) {
}
-// The FAT32 call first (extDiskSpace), then INT 21h AH=36h: AX sectors
-// per cluster, BX free clusters, CX bytes per sector, DX total clusters
-// (at most 2 GB). An empty drive fails through the critical-error
-// handler (AX = FFFFh).
-bool platformDiskSpace(const char *path, uint64_t *totalBytes, uint64_t *freeBytes) {
- int32_t drive = (path[0] && path[1] == ':') ? toupper((unsigned char)path[0]) - 'A' + 1 : DOS_DEFAULT_DRIVE;
-
- if (extDiskSpace(drive, totalBytes, freeBytes)) {
- return true;
- }
-
- __dpmi_regs r;
-
- memset(&r, 0, sizeof(r));
- r.x.ax = DOS_FN_DISK_FREE;
- r.x.dx = (uint16_t)drive;
- __dpmi_int(INT_DOS, &r);
-
- if (r.x.ax == DOS_DISK_INVALID) {
- return false;
- }
-
- uint64_t clusterSize = (uint64_t)r.x.ax * r.x.cx;
-
- *totalBytes = clusterSize * r.x.dx;
- *freeBytes = clusterSize * r.x.bx;
- return true;
-}
-
-
// IOCTL 4409h (remote?), 440Eh (the letter a shared floppy answers to)
// and 4408h (removable?) never read the media.
int32_t platformDriveList(PlatformDriveT *out, int32_t max) {
@@ -3593,6 +3507,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
DXE_EXPORT(prefsGetBool)
DXE_EXPORT(prefsGetChoice)
DXE_EXPORT(prefsGetInt)
+ DXE_EXPORT(prefsGetSecret)
DXE_EXPORT(prefsGetString)
DXE_EXPORT(prefsGetVideoMode)
DXE_EXPORT(prefsKeyName)
@@ -3602,6 +3517,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
DXE_EXPORT(prefsSaveAs)
DXE_EXPORT(prefsSetBool)
DXE_EXPORT(prefsSetInt)
+ DXE_EXPORT(prefsSetSecret)
DXE_EXPORT(prefsSetString)
DXE_EXPORT(prefsSetVideoMode)
DXE_EXPORT(prefsWallpaperChoices)
@@ -3612,6 +3528,8 @@ DXE_EXPORT_TABLE(sDxeExportTable)
DXE_EXPORT(platformAppUserDir)
DXE_EXPORT(platformBiosScanCode)
DXE_EXPORT(platformBootDrive)
+ DXE_EXPORT(platformBootNoteAdd)
+ DXE_EXPORT(platformBootNotes)
DXE_EXPORT(platformChdir)
DXE_EXPORT(platformClock)
DXE_EXPORT(platformClockHz)
@@ -3723,6 +3641,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
DXE_EXPORT(platformVideoSetPalette)
DXE_EXPORT(platformVideoShutdown)
DXE_EXPORT(platformVideoWaitRetrace)
+ DXE_EXPORT(platformWriteFile)
DXE_EXPORT(platformYield)
// --- DPMI/go32 (needed by rs232 serial driver) ---
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
index 14653fef..9e757560 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
@@ -48,6 +48,7 @@
#include "dvxPlat.h"
#include "dvxPlatHost.h"
+#include "dvxPlatInt.h"
#include "dvxPlatIsr.h"
#include "dvxPal.h"
@@ -59,7 +60,6 @@
#include
#include
#include
-#include
#include
#include
@@ -97,10 +97,6 @@
// Sentinel for "clock not yet started" in real-time mode.
#define WALL_CLOCK_UNSET 0
-// The one drive platformDriveList reports, and where platformDiskSpace
-// stops looking for an existing folder.
-#define HOST_ROOT "/"
-#define HOST_CURRENT_DIR "."
// Ring buffer of mouse state samples.
@@ -494,34 +490,6 @@ PlatformTicksT platformClockHz(void) {
}
-// statvfs of path, or of its nearest existing parent folder (a folder
-// about to be created reports the disk it will be created on).
-bool platformDiskSpace(const char *path, uint64_t *totalBytes, uint64_t *freeBytes) {
- char dir[DVX_MAX_PATH];
- struct statvfs sv;
-
- snprintf(dir, sizeof(dir), "%s", path[0] ? path : HOST_CURRENT_DIR);
-
- while (statvfs(dir, &sv) != 0) {
- if (strcmp(dir, HOST_ROOT) == 0 || strcmp(dir, HOST_CURRENT_DIR) == 0) {
- return false;
- }
-
- char *sep = platformPathDirEnd(dir);
-
- if (!sep) {
- snprintf(dir, sizeof(dir), "%s", HOST_CURRENT_DIR);
- } else {
- sep[sep == dir ? 1 : 0] = '\0';
- }
- }
-
- *totalBytes = (uint64_t)sv.f_blocks * sv.f_frsize;
- *freeBytes = (uint64_t)sv.f_bavail * sv.f_frsize;
- return true;
-}
-
-
int32_t platformDriveList(PlatformDriveT *out, int32_t max) {
if (max < 1) {
return 0;
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
index 55387eba..0c077645 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
@@ -29,6 +29,7 @@
// can still use them.
#include "dvxPlat.h"
+#include "dvxPlatInt.h"
#include "thirdparty/stb_ds_wrap.h"
#include
@@ -45,11 +46,30 @@
#ifdef __DJGPP__
#include
+#include
+#include
+#include
+#else
+#include
#endif
// Permission bits for directories created by platformMkdirRecursive (rwxr-xr-x).
#define MKDIR_MODE 0755
+// platformDiskSpace on DOS: INT 21h AH=36h (at most 2 GB), and the FAT32
+// call 7303h with its record (the version word at +2 goes in as 0) in the
+// transfer buffer after the "X:\" root
+#define DOS_FN_DISK_FREE 0x3600
+#define DOS_DISK_INVALID 0xFFFF
+#define DOS_DEFAULT_DRIVE 0 // 36h DL: the current drive
+#define DOS_FN_EXT_DISK_FREE 0x7303 // FAT32 DOS (7.1, FreeDOS): free space of the root at DS:DX into ES:DI, CX bytes
+#define EXT_FREE_SIZE 0x2C
+#define EXT_FREE_SEC_PER_CLUS 0x04
+#define EXT_FREE_BYTES_PER_SEC 0x08
+#define EXT_FREE_AVAIL_CLUS 0x0C
+#define EXT_FREE_TOTAL_CLUS 0x10
+#define EXT_FREE_ROOT_LEN 4
+
// Transfer block used by platformCopyFile.
#define COPY_FILE_CHUNK 4096
@@ -90,6 +110,9 @@
// Longest line the memory log takes (longer ones are cut)
#define LOG_LINE_MAX 512
+// The line that says the loader had more notes (platformBootNoteAdd)
+#define BOOT_NOTES_MORE "...\n"
+
// Scratch file platformDirIsWritable makes and removes
#define WRITE_PROBE_NAME "DVXWTEST.$$$"
@@ -145,10 +168,12 @@ int32_t dvxStrAppendf(char *buf, int32_t bufSize, int32_t pos, c
int32_t dvxTrimRight(char *buf);
static const ScanFoldT *findScanFold(int32_t scancode);
static bool globMatchLen(const char *pat, int32_t patLen, const char *name);
+static char **kindList(const char *dir);
static int kindNameCompare(const void *a, const void *b);
static void logMemAppend(const char *fmt, va_list ap);
static bool pathAbsolute(const char *path, char *out, int32_t outSize);
static bool pathCharEq(char a, char b);
+static int32_t pathSepCount(const char *path);
static int32_t pathTrimmedLen(const char *path);
char platformAltScanToChar(int32_t scancode);
void platformAppAbort(void);
@@ -156,9 +181,12 @@ void platformAppExit(int status);
bool platformAppUserDir(char *out, int32_t outSize, const char *appPath);
uint32_t platformBankChunk(uint32_t offset, uint32_t length, uint32_t granBytes, uint32_t winBytes, uint32_t *winStart);
int32_t platformBiosScanCode(int32_t key, int32_t mod);
+void platformBootNoteAdd(const char *text);
+const char *platformBootNotes(void);
int32_t platformChdir(const char *path);
bool platformCopyFile(const char *srcPath, const char *dstPath);
bool platformDirIsWritable(const char *dir);
+bool platformDiskSpace(const char *path, uint64_t *totalBytes, uint64_t *freeBytes);
const char *platformDvxIniPath(void);
bool platformFilterMatch(const char *name, const char *filter);
bool platformGlobMatch(const char *pattern, const char *name);
@@ -202,6 +230,7 @@ bool platformUserInTemp(void);
bool platformUserPath(char *out, int32_t outSize, const char *rel);
const char *platformValidateLongName(const char *name);
const char *platformValidateShortName(const char *name);
+bool platformWriteFile(const char *path, const void *data, int32_t len);
// ============================================================
@@ -219,8 +248,13 @@ static int32_t sLogMemLen = 0;
// Nothing can be saved: no writable user folder (platformUserInit)
static bool sReadOnly = false;
+// The loader's notes for the shell, one per line, and whether they ran out
+// of room (platformBootNoteAdd)
+static char sBootNotes[PLATFORM_BOOT_NOTES_MAX];
+static bool sBootNotesFull = false;
+
// The folder of each kind, in DvxKindE order
-static const char *const sKindDirs[DvxKindCountE] = { DVX_FONTS_DIR, DVX_SOUNDS_DIR, DVX_THEMES_DIR, DVX_WPAPER_DIR };
+static const char *const sKindDirs[DvxKindCountE] = { DVX_FONTS_DIR, DVX_SOUNDS_DIR, DVX_THEMES_DIR, DVX_WPAPER_DIR, DVX_BANKS_DIR };
// The user folder (platformUserDir; "" until first asked), whether it
// went in TEMP, and platformDvxIniPath's answer
@@ -537,6 +571,47 @@ static bool globMatchLen(const char *pat, int32_t patLen, const char *name) {
}
+// A kind folder's files: its own names, and VENDOR\NAME for the files in
+// each folder inside it (dvxReadDir's array; NULL when it cannot be read).
+static char **kindList(const char *dir) {
+ char **names = dvxReadDir(dir);
+ char **out = NULL;
+
+ for (int32_t i = 0; i < (int32_t)arrlen(names); i++) {
+ char sub[DVX_MAX_PATH];
+ struct stat st;
+
+ if (!platformPathJoin(sub, sizeof(sub), dir, names[i]) || stat(sub, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ arrput(out, names[i]);
+ continue;
+ }
+
+ char **inner = dvxReadDir(sub);
+
+ for (int32_t j = 0; j < (int32_t)arrlen(inner); j++) {
+ char name[DVX_MAX_PATH];
+ char full[DVX_MAX_PATH];
+ struct stat innerSt;
+
+ // One level: a folder in a vendor's folder is not listed
+ if (platformPathJoin(name, sizeof(name), names[i], inner[j]) && platformPathJoin(full, sizeof(full), dir, name) && stat(full, &innerSt) == 0 && !S_ISDIR(innerSt.st_mode)) {
+ char *copy = strdup(name);
+
+ if (copy) {
+ arrput(out, copy);
+ }
+ }
+ }
+
+ dvxReadDirFree(inner);
+ free(names[i]);
+ }
+
+ arrfree(names);
+ return out;
+}
+
+
// qsort: file names, case ignored (platformKindReadDir)
static int kindNameCompare(const void *a, const void *b) {
return strcasecmp(*(const char *const *)a, *(const char *const *)b);
@@ -601,6 +676,18 @@ static bool pathCharEq(char a, char b) {
}
+// The separators in a path ('/' and '\\' alike).
+static int32_t pathSepCount(const char *path) {
+ int32_t n = 0;
+
+ for (const char *p = path; *p; p++) {
+ n += platformPathIsSep(*p) ? 1 : 0;
+ }
+
+ return n;
+}
+
+
// Length of path without a trailing separator (a lone "/" becomes 0).
static int32_t pathTrimmedLen(const char *path) {
int32_t len = (int32_t)strlen(path);
@@ -718,6 +805,29 @@ int32_t platformBiosScanCode(int32_t key, int32_t mod) {
}
+void platformBootNoteAdd(const char *text) {
+ size_t used = strlen(sBootNotes);
+
+ if (sBootNotesFull) {
+ return;
+ }
+
+ // Room is kept for one "..." line to say there were more
+ if (used + strlen(text) + 1 + sizeof(BOOT_NOTES_MORE) > sizeof(sBootNotes)) {
+ snprintf(sBootNotes + used, sizeof(sBootNotes) - used, "%s", BOOT_NOTES_MORE);
+ sBootNotesFull = true;
+ return;
+ }
+
+ snprintf(sBootNotes + used, sizeof(sBootNotes) - used, "%s\n", text);
+}
+
+
+const char *platformBootNotes(void) {
+ return sBootNotes;
+}
+
+
int32_t platformChdir(const char *path) {
#ifdef __DJGPP__
if (path[0] && path[1] == ':') {
@@ -802,6 +912,85 @@ bool platformDirIsWritable(const char *dir) {
}
+// DOS: the FAT32 call 7303h first (32-bit cluster counts, so a disk past
+// 2 GB reports its true size; DOS without it leaves the record zero),
+// then INT 21h AH=36h (AX sectors per cluster, BX free clusters, CX bytes
+// per sector, DX total clusters; at most 2 GB). An empty drive fails
+// through the critical-error handler (AX = FFFFh). Elsewhere: statvfs of
+// path, or of its nearest existing parent folder (a folder about to be
+// created reports the disk it will be created on).
+bool platformDiskSpace(const char *path, uint64_t *totalBytes, uint64_t *freeBytes) {
+#ifdef __DJGPP__
+ static const uint8_t zero[EXT_FREE_SIZE];
+ int32_t drive = (path[0] && path[1] == ':') ? toupper((unsigned char)path[0]) - 'A' + 1 : DOS_DEFAULT_DRIVE;
+ char root[EXT_FREE_ROOT_LEN] = "A:" DVX_PATH_SEP;
+ uint32_t rec = __tb + EXT_FREE_ROOT_LEN;
+ __dpmi_regs r;
+
+ root[0] = (char)('A' + ((drive == DOS_DEFAULT_DRIVE) ? getdisk() : drive - 1));
+ dosmemput(root, sizeof(root), __tb);
+ dosmemput(zero, sizeof(zero), rec);
+ memset(&r, 0, sizeof(r));
+ r.x.ax = DOS_FN_EXT_DISK_FREE;
+ r.x.ds = (uint16_t)(__tb >> REAL_MODE_SEG_SHIFT);
+ r.x.dx = (uint16_t)(__tb & REAL_MODE_OFF_MASK);
+ r.x.es = (uint16_t)(rec >> REAL_MODE_SEG_SHIFT);
+ r.x.di = (uint16_t)(rec & REAL_MODE_OFF_MASK);
+ r.x.cx = EXT_FREE_SIZE;
+ __dpmi_int(INT_DOS, &r);
+
+ uint32_t secPerClus = _farpeekl(_dos_ds, rec + EXT_FREE_SEC_PER_CLUS);
+ uint32_t bytesPerSec = _farpeekl(_dos_ds, rec + EXT_FREE_BYTES_PER_SEC);
+
+ if (!(r.x.flags & CPU_FLAG_CF) && secPerClus != 0 && bytesPerSec != 0) {
+ uint64_t clusterSize = (uint64_t)secPerClus * bytesPerSec;
+
+ *totalBytes = clusterSize * _farpeekl(_dos_ds, rec + EXT_FREE_TOTAL_CLUS);
+ *freeBytes = clusterSize * _farpeekl(_dos_ds, rec + EXT_FREE_AVAIL_CLUS);
+ return true;
+ }
+
+ memset(&r, 0, sizeof(r));
+ r.x.ax = DOS_FN_DISK_FREE;
+ r.x.dx = (uint16_t)drive;
+ __dpmi_int(INT_DOS, &r);
+
+ if (r.x.ax == DOS_DISK_INVALID) {
+ return false;
+ }
+
+ uint64_t clusterSize = (uint64_t)r.x.ax * r.x.cx;
+
+ *totalBytes = clusterSize * r.x.dx;
+ *freeBytes = clusterSize * r.x.bx;
+ return true;
+#else
+ char dir[DVX_MAX_PATH];
+ struct statvfs sv;
+
+ snprintf(dir, sizeof(dir), "%s", path[0] ? path : HOST_CURRENT_DIR);
+
+ while (statvfs(dir, &sv) != 0) {
+ if (strcmp(dir, HOST_ROOT) == 0 || strcmp(dir, HOST_CURRENT_DIR) == 0) {
+ return false;
+ }
+
+ char *sep = platformPathDirEnd(dir);
+
+ if (!sep) {
+ snprintf(dir, sizeof(dir), "%s", HOST_CURRENT_DIR);
+ } else {
+ sep[sep == dir ? 1 : 0] = '\0';
+ }
+ }
+
+ *totalBytes = (uint64_t)sv.f_blocks * sv.f_frsize;
+ *freeBytes = (uint64_t)sv.f_bavail * sv.f_frsize;
+ return true;
+#endif
+}
+
+
const char *platformDvxIniPath(void) {
if (!platformUserPath(sDvxIni, sizeof(sDvxIni), DVX_INI_NAME)) {
snprintf(sDvxIni, sizeof(sDvxIni), "%s", DVX_INI_NAME);
@@ -879,15 +1068,18 @@ bool platformKindFind(char *out, int32_t outSize, DvxKindE kind, const char *nam
return false;
}
- // A name with a folder in it is a path of its own
- if (platformPathDirEnd(name)) {
- return snprintf(out, (size_t)outSize, "%s", name) < outSize && stat(out, &st) == 0 && S_ISREG(st.st_mode);
+ // NAME, or an add-on's VENDOR\NAME: in the kind's folders
+ if (!platformPathIsAbsolute(name) && pathSepCount(name) <= 1) {
+ for (int32_t i = 0; i < DVX_ARRAY_LEN(userFirst); i++) {
+ if (platformKindDir(dir, sizeof(dir), kind, userFirst[i]) && platformPathJoin(out, outSize, dir, name) && stat(out, &st) == 0 && S_ISREG(st.st_mode)) {
+ return true;
+ }
+ }
}
- for (int32_t i = 0; i < DVX_ARRAY_LEN(userFirst); i++) {
- if (platformKindDir(dir, sizeof(dir), kind, userFirst[i]) && platformPathJoin(out, outSize, dir, name) && stat(out, &st) == 0 && S_ISREG(st.st_mode)) {
- return true;
- }
+ // Any other name with a folder in it is a path of its own
+ if (platformPathDirEnd(name)) {
+ return snprintf(out, (size_t)outSize, "%s", name) < outSize && stat(out, &st) == 0 && S_ISREG(st.st_mode);
}
return false;
@@ -903,17 +1095,28 @@ bool platformKindNameOf(DvxKindE kind, const char *path, char *out, int32_t outS
static const bool userFirst[] = { true, false };
char fileDir[DVX_MAX_PATH];
char absFile[DVX_MAX_PATH];
+ char vendorDir[DVX_MAX_PATH];
char dir[DVX_MAX_PATH];
char absDir[DVX_MAX_PATH];
- if (!path || !platformPathDirName(path, fileDir, sizeof(fileDir)) || !pathAbsolute(fileDir, absFile, sizeof(absFile))) {
+ if (!path || !platformPathDirName(path, fileDir, sizeof(fileDir)) || !pathAbsolute(fileDir, absFile, sizeof(absFile)) || !platformPathDirName(absFile, vendorDir, sizeof(vendorDir))) {
return false;
}
for (int32_t i = 0; i < DVX_ARRAY_LEN(userFirst); i++) {
- if (platformKindDir(dir, sizeof(dir), kind, userFirst[i]) && pathAbsolute(dir, absDir, sizeof(absDir)) && platformPathSame(absFile, absDir)) {
+ if (!platformKindDir(dir, sizeof(dir), kind, userFirst[i]) || !pathAbsolute(dir, absDir, sizeof(absDir))) {
+ continue;
+ }
+
+ // Directly in the kind's folder: NAME
+ if (platformPathSame(absFile, absDir)) {
return snprintf(out, (size_t)outSize, "%s", platformPathBaseName(path)) < outSize;
}
+
+ // In a vendor's folder there: VENDOR\NAME
+ if (platformPathSame(vendorDir, absDir)) {
+ return platformPathJoin(out, outSize, platformPathBaseName(absFile), platformPathBaseName(path));
+ }
}
return false;
@@ -923,8 +1126,8 @@ bool platformKindNameOf(DvxKindE kind, const char *path, char *out, int32_t outS
char **platformKindReadDir(DvxKindE kind) {
char dir[DVX_MAX_PATH];
char **names = NULL;
- char **user = platformKindDir(dir, sizeof(dir), kind, true) ? dvxReadDir(dir) : NULL;
- char **shared = platformKindDir(dir, sizeof(dir), kind, false) ? dvxReadDir(dir) : NULL;
+ char **user = platformKindDir(dir, sizeof(dir), kind, true) ? kindList(dir) : NULL;
+ char **shared = platformKindDir(dir, sizeof(dir), kind, false) ? kindList(dir) : NULL;
int32_t userCount;
if (user) {
@@ -1548,3 +1751,30 @@ const char *platformValidateShortName(const char *name) {
return NULL;
}
+
+
+bool platformWriteFile(const char *path, const void *data, int32_t len) {
+ char tmpPath[DVX_MAX_PATH];
+ char bakPath[DVX_MAX_PATH];
+ FILE *f;
+ bool ok;
+
+ if (!platformPathSibling(tmpPath, sizeof(tmpPath), path, PLATFORM_TMP_EXT) || !platformPathSibling(bakPath, sizeof(bakPath), path, PLATFORM_BAK_EXT)) {
+ return false;
+ }
+
+ f = fopen(tmpPath, "wb");
+ ok = f != NULL && (int32_t)fwrite(data, 1, (size_t)len, f) == len;
+
+ // fclose flushes: a late write error surfaces here
+ if (f && fclose(f) != 0) {
+ ok = false;
+ }
+
+ if (!ok) {
+ remove(tmpPath);
+ return false;
+ }
+
+ return platformReplaceFile(tmpPath, path, bakPath);
+}
diff --git a/src/libs/kpunch/libdvx/sdkGuide.dhs b/src/libs/kpunch/libdvx/sdkGuide.dhs
index 1a0e9f68..8665c248 100644
--- a/src/libs/kpunch/libdvx/sdkGuide.dhs
+++ b/src/libs/kpunch/libdvx/sdkGuide.dhs
@@ -90,7 +90,7 @@ Every project Makefile only names the SDK, the kind, and the module; the rules c
Command Does
------- ----
make Builds the module with its resources, and NAME.hlp from the help source
- make package Lays out pkg/ exactly as the files go into the DVX directory
+ make package Lays out pkg/ exactly as the files go into the DVX directory, and makes NAME.dpk, the add-on package Software installs
make install DVX_DIR=/path/to/dvx Packages, then copies pkg/ into a DVX installation
make clean Removes everything the build made
.endtable
@@ -537,10 +537,13 @@ The DVX side of these rules (what bumps the number) is under Binary Interface Ve
.index Packaging
.index make package
.index VENDOR
+.index PKG_KEY
+.index PKG_VERSION
+.index dvxpkg
.h1 Packaging and Installing
-make package lays out pkg/ exactly as the files go into the DVX directory; make install DVX_DIR=... copies it there. VENDOR (default local) names the vendor directory; use a short name of your own (8 characters at most).
+make package lays out pkg/ exactly as the files go into the DVX directory and makes NAME.dpk from it, the add-on package anyone installs with DVX's Software program; make install DVX_DIR=... copies pkg/ into a DVX directory. VENDOR (default local) names the vendor directory; use a short name of your own (8 characters at most: a-z, 0-9, and _, since the package name is VENDOR.NAME in lower case).
.table
Kind Directory Files
@@ -554,6 +557,41 @@ make package lays out pkg/ exactly as the files go into the DVX directory; make
.item Programs carry their compiled help. Widgets and libraries carry help sources, which DVX compiles into its system help when the set of installed modules changes.
.item .dep files are written with DOS line endings.
.item A program's data files (levels, graphics, sounds it loads at run time) go in the Makefile's DATA variable, for example DATA = levels sounds; the package puts them in the DATA folder beside NAME.app, where the program finds them through shellDataPath (BASIC: App.Data). Small ones can instead travel inside the .app as binary resources (see the .res file).
-.item To ship a module, zip the contents of pkg/; the user unpacks it into the DVX directory. A library's header and .bas file go to the developers who use it, not into the DVX directory.
-.item Removing a module is deleting its directory.
+.item To ship a module, give out NAME.dpk: Software installs it (and what it needs from the same folder), records it, and removes it cleanly again. A library's header and .bas file go to the developers who use it, not into the package.
.endlist
+
+.h2 The Add-on Package
+
+The package's details come from sdk.mk settings, on the make command line or in the project Makefile:
+
+.table
+ Setting Meaning
+ ------- -------
+ PKG_TITLE The name Software shows (default NAME)
+ PKG_VERSION major.minor.patch (default 1.0.0); raise it for every version you hand out, so Software installs it over the old one
+ PKG_SUMMARY A sentence about it
+ PKG_PUBLISHER Who publishes it; also PKG_AUTHOR, PKG_LICENCE, and PKG_HOMEPAGE
+ PKG_NEEDS The packages it needs, id=oldest-version each: PKG_NEEDS="acme.cards=1.0 acme.dice=2.1"
+ PKG_KEY The secret key file to sign it with
+.endtable
+
+Programs, libraries, and widgets are marked with the binary interface version of the SDK that built them (see Binary Interface Version); Software will not install them on a DVX of another version. Sign packages so the people who install them can tell they are yours:
+
+.code
+bin/dvxpkg keygen "Acme Games" acme # once: acme.SEC and acme.KEY
+make package PKG_KEY=acme.SEC PKG_VERSION=1.1.0
+bin/dvxpkg info NAME.dpk # what the package says
+.endcode
+
+Give acme.KEY to the people who install your packages (Software, File > Trust a Publisher's Key) and keep acme.SEC private and backed up: anyone who has it can sign as you, and an update must be signed with the key that signed the version installed. bin/dvxpkg also signs and checks packages and lists their contents; on DVX, SYSTEM/DVXPKG.EXE does the same, and installs and removes packages. The package library and its file format are described under Package Library.
+
+.h2 Publishing on the Package Server
+
+Software's Available and Updates pages list the packages on the DVX package server. To publish one there, make an account on the server (POST /v1/account/register, then the link in the mail), give its email and password in Software's File > Package Server, and choose File > Publish a Package; Software's Published page shows each version's review. From the build machine, sign in (POST /v1/account/login gives a session token) and upload the package with it:
+
+.code
+curl -H "Authorization: Bearer TOKEN" -H "Content-Type: application/octet-stream" \
+ --data-binary @NAME.dpk http://SERVER/v1/upload
+.endcode
+
+The server checks the package as DVX does before installing it and holds it for review; GET /v1/mine shows its state, and the note when it is turned down. Once the server's operator approves it, it is signed with DVX's repository key and listed, and every DVX that has an older version is offered the update. An id belongs to the account that first uploaded it, so later versions must come from the same account; ids of the vendor kpunch are DVX's own. The server's API and its rules are in server/README.md in the DVX source tree.
diff --git a/src/libs/kpunch/listhelp/listHelpBas.c b/src/libs/kpunch/listhelp/listHelpBas.c
index 8bd6266e..d77d5430 100644
--- a/src/libs/kpunch/listhelp/listHelpBas.c
+++ b/src/libs/kpunch/listhelp/listHelpBas.c
@@ -43,15 +43,13 @@
#include "dvxMem.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
+#include "dvxBas.h"
#include
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
-#define BAS_NO_HANDLE 0
#define BAS_NONE (-1)
#define ITEM_SEPARATOR '\n'
#define LABEL_LINE_FIRST 1 // ListIconLabel's line numbers
diff --git a/src/libs/kpunch/music/music.c b/src/libs/kpunch/music/music.c
index 7ed332cf..2e4ceae1 100644
--- a/src/libs/kpunch/music/music.c
+++ b/src/libs/kpunch/music/music.c
@@ -58,9 +58,6 @@
// ABI stamp checked by the loader (see DVX_ABI_VERSION).
DVX_ABI_STAMP(music);
-// DVX.INI [audio] midiBank: the OP2 bank (path from the DVX folder)
-#define MUSIC_INI_BANK "midiBank"
-#define MUSIC_BANK_DEFAULT "libs" DVX_PATH_SEP "kpunch" DVX_PATH_SEP "music" DVX_PATH_SEP "genmidi.op2"
#define MODULE_STREAM_MS 400
#define MIDI_STREAM_MS 150
@@ -122,6 +119,7 @@ static int32_t sTimerId = TIMER_NONE;
// Prototypes
// ============================================================
+static bool bankRead(const char *path, Op2BankT *bank);
static int32_t ctxEnsure(void);
static int32_t decideMidi(MusicT *m);
static int32_t gainOf(const MusicT *m);
@@ -168,6 +166,17 @@ static void silence(MusicT *m);
static void timerUpdate(void);
+// An OP2 bank file into bank; false when it is missing or damaged.
+static bool bankRead(const char *path, Op2BankT *bank) {
+ int32_t len = 0;
+ char *data = platformReadFile(path, &len);
+ bool ok = data && op2Parse((const uint8_t *)data, (uint32_t)len, bank) == MUSIC_OK;
+
+ free(data);
+ return ok;
+}
+
+
// The library's own context, opened for no program.
static int32_t ctxEnsure(void) {
if (!sCtx) {
@@ -393,15 +402,28 @@ const Op2BankT *musicBank(int32_t *err) {
return sBank;
}
- PrefsHandleT *prefs = prefsLoad(platformDvxIniPath());
- int32_t len = 0;
- char *data = platformReadFile(prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, MUSIC_BANK_DEFAULT), &len);
- Op2BankT *bank = (Op2BankT *)malloc(sizeof(Op2BankT));
+ PrefsHandleT *prefs = prefsLoad(platformDvxIniPath());
+ const char *setting = prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, "");
+ char path[DVX_MAX_PATH];
+ Op2BankT *bank = (Op2BankT *)malloc(sizeof(Op2BankT));
+ bool ok = false;
- prefsClose(prefs);
*err = bank ? MUSIC_ERR_BANK : MUSIC_ERR_NO_MEMORY;
- if (bank && data && op2Parse((const uint8_t *)data, (uint32_t)len, bank) == MUSIC_OK && platformLockRegion(bank, sizeof(Op2BankT), false) == 0) {
+ // The bank DVX.INI names (in the banks folders, or by its path); the
+ // bundled one when it names none, or one that is gone or damaged
+ if (bank && setting[0]) {
+ ok = platformKindFind(path, sizeof(path), DvxKindBanksE, setting) && bankRead(path, bank);
+
+ if (!ok) {
+ dvxLog("Music: the MIDI bank %s is missing or damaged; using the standard one", setting);
+ }
+ }
+
+ ok = ok || (bank && bankRead(MUSIC_BANK_DEFAULT, bank));
+ prefsClose(prefs);
+
+ if (ok && platformLockRegion(bank, sizeof(Op2BankT), false) == 0) {
sBank = bank;
*err = MUSIC_OK;
} else {
@@ -409,7 +431,6 @@ const Op2BankT *musicBank(int32_t *err) {
free(bank);
}
- free(data);
return sBank;
}
diff --git a/src/libs/kpunch/music/music.dhs b/src/libs/kpunch/music/music.dhs
index 821a9510..406915ac 100644
--- a/src/libs/kpunch/music/music.dhs
+++ b/src/libs/kpunch/music/music.dhs
@@ -94,7 +94,7 @@ On the FM chip and the MPU-401 port the song is played by the timer service (500
.h2 General MIDI on FM
-Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank names another OP2 file instead (a path from the DVX folder), for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. The bank is read once, at the first MIDI music that needs it.
+Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). The bank is read once, at the first MIDI music that needs it, so a new choice takes effect when DVX next starts.
What is played: note on / off, velocity, program change, channel volume (CC7), expression (CC11), pan (CC10: left, both, or right on an OPL3 and the software synthesizer), sustain pedal (CC64), pitch bend with the bend range from RPN 0 (default 2 semitones), all notes off (CC123, CC120), reset controllers (CC121), and General MIDI System On (a reset). Channel 10 plays percussion. Loudness is velocity x volume x expression x the music's volume on a 40 log10 curve, added as attenuation to the voice's level. OP2 banks are tuned for DMX, which sounds note n where MIDI has note n + 12, so notes are played an octave up from the bank's offsets. Not played: modulation (CC1), aftertouch, the FM chip's own rhythm mode, four-operator voices.
diff --git a/src/libs/kpunch/music/music.h b/src/libs/kpunch/music/music.h
index 470603cf..5e0d576c 100644
--- a/src/libs/kpunch/music/music.h
+++ b/src/libs/kpunch/music/music.h
@@ -33,7 +33,7 @@
// midi = mpu401 (General MIDI, or a Roland MT-32 with
// mpuMode), else the FM chip (OPL2 / OPL3) with the General
// MIDI instrument bank (DVX.INI [audio] midiBank, an OP2
-// file, default the bundled Freedoom GENMIDI bank), else the
+// bank, default the bundled Freedoom GENMIDI bank), else the
// library's own FM software synthesizer, which sounds like
// the FM chip and plays on any card that plays sampled
// sound. When the FM chip runs out of voices, extra notes
@@ -64,6 +64,7 @@
#define MUSIC_LIB_H
#include "dvxTypes.h"
+#include "dvxPlat.h"
#include
#include
@@ -78,6 +79,13 @@
#define MUSIC_MIDI_BEND_MAX 8191
// Text sizes in MusicInfoT (with the terminating zero)
+// DVX.INI [audio] midiBank: the FM instrument bank, an OP2 file named as
+// the banks kind names it (NAME or VENDOR\NAME in SHARED\BANKS or the
+// user's BANKS folder; platformKindFind) or by its path. Not set, or not
+// found: the bundled one. Read once, when MIDI first plays.
+#define MUSIC_INI_BANK "midiBank"
+#define MUSIC_BANK_DEFAULT "libs" DVX_PATH_SEP "kpunch" DVX_PATH_SEP "music" DVX_PATH_SEP "genmidi.op2"
+
#define MUSIC_TITLE_MAX 64
#define MUSIC_DESC_MAX 64
diff --git a/src/libs/kpunch/music/musicBas.c b/src/libs/kpunch/music/musicBas.c
index c85dcca0..16d95467 100644
--- a/src/libs/kpunch/music/musicBas.c
+++ b/src/libs/kpunch/music/musicBas.c
@@ -33,13 +33,12 @@
// program's macro queue.
#include "musicInt.h"
+#include "dvxBas.h"
#include
#include
#include
-#define BAS_TRUE (-1)
-#define BAS_FALSE 0
#define BAS_SLOTS 64
#define BAS_FIRST_CHANNEL 1
#define PERCENT_MAX 100
diff --git a/src/libs/kpunch/pkg/Makefile b/src/libs/kpunch/pkg/Makefile
new file mode 100644
index 00000000..58ece387
--- /dev/null
+++ b/src/libs/kpunch/pkg/Makefile
@@ -0,0 +1,94 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+# Package Library Makefile for DJGPP cross-compilation
+#
+# Builds pkg.lib (a DXE3 module): add-on packages -- the ZIP reader and
+# writer, manifests, keys and Ed25519 signatures, .DPK files, the
+# installed-package records, the keyring, planning, and the stepwise
+# install and remove operations. miniz (inflate and
+# deflate, no archive API) and Monocypher (with its Ed25519 file) are
+# built in; SETUP.EXE and the package tool compile the same sources.
+# Also SYSTEM\DVXPKG.EXE, the package tool on DOS (pkgDosMain.c over
+# pkgCli.c), a program of its own like DVXRES.EXE.
+
+include ../../../common.mk
+
+MINIZ = thirdparty/miniz
+MONO = thirdparty/monocypher/src
+CFLAGS = $(DOS_CFLAGS) -I. -I.. -I../libdvx -I../libdvx/platform -I../libdvx/thirdparty -I$(MINIZ) -I$(MONO) $(PKG_MINIZ_FLAGS)
+
+OBJDIR = ../../../../obj/pkg
+LIBSDIR = ../../../../bin/libs
+
+SRCS = pkgBas.c pkgDb.c pkgDpk.c pkgFormat.c pkgIni.c pkgManifest.c pkgOp.c pkgPlan.c pkgSign.c pkgTrust.c pkgZip.c pkgZipWrite.c $(MINIZ)/miniz.c $(MONO)/monocypher.c $(MONO)/optional/monocypher-ed25519.c
+OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(notdir $(SRCS)))
+
+TARGETDIR = $(LIBSDIR)/kpunch/pkg
+TARGET = $(TARGETDIR)/pkg.lib
+
+SYSTEMDIR = ../../../../bin/system
+TOOL = $(SYSTEMDIR)/DVXPKG.EXE
+TOOLOBJDIR = $(OBJDIR)/tool
+TOOL_SRCS = pkgDosMain.c pkgCli.c $(filter-out pkgBas.c,$(SRCS)) ../serial/security/secHash.c ../serial/security/security.c ../libdvx/platform/dvxPlatformUtil.c ../libdvx/thirdparty/stb_ds_impl.c
+TOOL_OBJS = $(patsubst %.c,$(TOOLOBJDIR)/%.o,$(notdir $(TOOL_SRCS)))
+
+vpath %.c . $(MINIZ) $(MONO) $(MONO)/optional ../serial/security ../libdvx/platform ../libdvx/thirdparty
+
+.PHONY: all clean
+
+all: $(TARGET) $(TARGETDIR)/pkg.dep $(TOOL)
+
+$(TARGETDIR)/pkg.dep: pkg.dep | $(TARGETDIR)
+ sed 's/$$/\r/' $< > $@
+
+$(TARGET): $(OBJS) | $(TARGETDIR)
+ $(DXE3GEN) -o $(TARGETDIR)/pkg.dxe -U $(OBJS)
+ mv $(TARGETDIR)/pkg.dxe $@
+
+$(OBJDIR)/%.o: %.c $(DVX_MAKEFILES) | $(OBJDIR)
+ $(CC) $(CFLAGS) -c -o $@ $<
+
+# DOS executable: link (stripped: it ships on the floppies), convert to
+# COFF, prepend the DPMI stub
+$(TOOL): $(TOOL_OBJS) | $(SYSTEMDIR)
+ $(CC) -s -o $(SYSTEMDIR)/dvxpkg.exe $^ -lm
+ $(EXE2COFF) $(SYSTEMDIR)/dvxpkg.exe
+ cat $(CWSDSTUB) $(SYSTEMDIR)/dvxpkg > $@
+ rm -f $(SYSTEMDIR)/dvxpkg $(SYSTEMDIR)/dvxpkg.exe
+
+$(TOOLOBJDIR)/%.o: %.c $(DVX_MAKEFILES) | $(TOOLOBJDIR)
+ $(CC) $(CFLAGS) -c -o $@ $<
+
+$(TOOLOBJDIR) $(SYSTEMDIR):
+ mkdir -p $@
+
+$(OBJDIR):
+ mkdir -p $(OBJDIR)
+
+$(TARGETDIR):
+ mkdir -p $(TARGETDIR)
+
+-include $(OBJS:.o=.d) $(TOOL_OBJS:.o=.d)
+
+clean:
+ rm -rf $(OBJS) $(OBJS:.o=.d) $(TARGET) $(TARGETDIR)/pkg.dep $(TARGETDIR) $(TOOLOBJDIR) $(TOOL)
diff --git a/src/libs/kpunch/pkg/pkg.dep b/src/libs/kpunch/pkg/pkg.dep
new file mode 100644
index 00000000..ffb0ed6e
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkg.dep
@@ -0,0 +1,2 @@
+libdvx
+serial
diff --git a/src/libs/kpunch/pkg/pkg.dhs b/src/libs/kpunch/pkg/pkg.dhs
new file mode 100644
index 00000000..3919fda2
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkg.dhs
@@ -0,0 +1,170 @@
+# The MIT License (MIT)
+#
+# Copyright (C) 2026 Scott Duensing
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+.section Technical Reference (for Developers)|Libraries
+.topic lib.pkg
+.title Package Library
+.toc 0 Package Library
+.index pkg
+.index Add-on packages
+.index DPK files
+.index PACKAGE.INI
+.index pkgDpkBuild
+.index pkgDpkBuildFiles
+.index pkgDpkSign
+.index pkgPlanInstall
+.index pkgOpInstall
+.index pkgOpRemove
+.index pkgKeyringLoad
+.index DVXPKG
+.index pkg.bas
+
+.h1 Package Library
+
+Add-on packages: the .DPK file format, manifests, Ed25519 keys and signatures, the packages installed in a DVX folder, the keys DVX trusts, planning what an install or removal takes, and installing and removing a package a step at a time. Software, Package Builder, the IDE's File > Make Package, DVXPKG.EXE, the build machine's dvxpkg, and Setup all use it.
+
+Header: pkg/pkg.h (it includes the others below)
+
+Loaded as: bin/libs/kpunch/pkg/pkg.lib
+
+BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys).
+
+.h2 A Package
+
+A package is NAME.DPK: a ZIP read front to back, whose first entry is PACKAGE.INI, the manifest; then PACKAGE.SIG when the publisher signed it; then the files, named relative to the DVX folder with / between parts.
+
+.code
+[package]
+format = 1
+id = acme.chess ; vendor.name, each 1..8 of a-z 0-9 _
+name = Chess
+version = 1.2.0
+kind = app ; app library widget font bank theme wallpaper sound help bundle system
+summary = Play chess against the computer.
+publisher = Acme Games ; also author, licence, homepage
+dvx = 1.0.0 ; the oldest DVX it runs on
+abi = 10 ; programs, libraries, widgets: DVX_ABI_VERSION
+[depends]
+acme.cards = 1.0.0 ; id = the oldest version that will do
+[files]
+APPS/ACME/CHESS/CHESS.APP = 61234
+.endcode
+
+Every file is listed with its size and SHA-256 and every listed file is in the package, so the one signature over the manifest's bytes covers everything. Each kind keeps to its own folders (pkgKindFolder gives the folder for an id; pkgFileAllowed checks a name):
+
+.table
+ Kind Folder
+ ---- ------
+ app, help APPS/VENDOR/NAME (help may also go beside a library or widget of its name)
+ library, widget LIBS/VENDOR/NAME, WIDGETS/VENDOR/NAME
+ font, bank SHARED/FONTS/VENDOR, SHARED/BANKS/VENDOR
+ theme, wallpaper SHARED/THEMES/VENDOR, SHARED/WPAPER/VENDOR
+ sound SHARED/SOUNDS/VENDOR
+ bundle no files: a set of packages, its dependencies
+ system DVX itself (kpunch.dvx): anywhere but the user folder
+.endtable
+
+.h2 Manifests (pkgManifest.h)
+
+.table
+ Function Description
+ -------- -----------
+ pkgManifestParse(text, len, &m, err, size) A manifest's text; then pkgManifestCheck before trusting it.
+ pkgManifestCheck(&m, err, size) The id, the kind's rules, every file's name and folder, nothing listed twice, no dependency on itself.
+ pkgManifestFormat(&m, &len) The text (CR LF lines), malloc'd.
+ pkgTemplateRead(path, &m, err, size) A template: a manifest whose format and [files] a build writes, so it may leave them out; other sections are ignored.
+ pkgKindFolder(kind, id, out, size) The folder a package id of kind keeps its files in (APPS/ACME/CHESS); false for bundle and system.
+ pkgKindIsCode(kind) Programs, libraries, and widgets: their manifests need an abi.
+ pkgIdValid(id), pkgIdSplit(id, vendor, name) Package ids.
+ pkgVersionParse, pkgVersionFormat, pkgVersionCompare major.minor.patch versions.
+.endtable
+
+.h2 Keys and Signatures (pkgSign.h)
+
+A key pair is NAME.KEY (public: id, name, public key) and NAME.SEC (secret: id, name, seed), small INI files. A key's id is the first 8 bytes of its public key's SHA-256, so a signature names its key. The repository key that signs DVX's own packages is kept offline; the server never holds a secret key.
+
+.table
+ Function Description
+ -------- -----------
+ pkgRandomBytes(buf, len) Random bytes for a seed: the security library's generator topped up with fresh hardware entropy.
+ pkgKeyWrite(seed, name, base, &key, err, size) Make a pair from seed and write BASE.KEY and BASE.SEC.
+ pkgKeyRead, pkgSecretRead, pkgSigRead Read a key, secret key, or signature file.
+ pkgSign(&secret, msg, len, &sig) Sign bytes (a manifest's).
+ pkgSigCheck(&sig, &key, msg, len) True when key made sig over the bytes.
+ pkgSecretWipe(&secret) Clear a secret key from memory once it has signed.
+.endtable
+
+.h2 Building and Reading Packages (pkgDpk.h)
+
+.table
+ Function Description
+ -------- -----------
+ pkgDpkBuild(&templ, folder, out, secret, err, size) Build from a template and every file under folder, named relative to it (a folder laid out like the DVX folder); secret NULL: unsigned. A program, library, or widget without abi gets DVX_ABI_VERSION.
+ pkgDpkBuildFiles(&templ, files, count, out, secret, err, size) The same from a list of PkgDpkFileT (the name in the package, the file it comes from).
+ pkgDpkFolderFiles(folder, prefix, &files) Every file under folder as that list, named prefix/...; pkgDpkFilesFree frees it.
+ pkgDpkReadInfo(path, &info, err, size) The manifest and PACKAGE.SIG, checked; pkgDpkInfoFree.
+ pkgDpkCheckFiles(path, &info, err, size) Every file's size and SHA-256 against the manifest.
+ pkgDpkSign(path, &secret, detached, err, size) Sign into the package, or beside it as NAME.SIG.
+ pkgDpkSigner(&info, detached, keys, count) Which of keys made the signature (or the detached one).
+.endtable
+
+.code
+PkgManifestT templ;
+PkgSecretT secret;
+char err[PKG_ERR_MAX];
+bool ok;
+
+ok = pkgTemplateRead("CHESS.INI", &templ, err, sizeof(err)) && pkgSecretRead("ACME.SEC", &secret, err, sizeof(err));
+ok = ok && pkgDpkBuild(&templ, "STAGE", "CHESS.DPK", &secret, err, sizeof(err));
+pkgSecretWipe(&secret);
+pkgManifestFree(&templ);
+.endcode
+
+.h2 Installed Packages and Trust (pkgDb.h, pkgTrust.h)
+
+Each installed package has a record, SYSTEM/PACKAGES/VENDOR/NAME.INI: its manifest as installed, whether it was chosen or only needed by another, and the key that signed it. pkgDbLoad reads them all; pkgDbFind, pkgDbOwner (who has a file), pkgDbPut, and pkgDbDrop keep them. Setup writes DVX's own record (kpunch.dvx, with no files).
+
+pkgKeyringLoad reads the keys DVX trusts: SYSTEM/KEYS (DVX's own) and the user's KEYS folder (publishers the user chose); pkgKeyringAdd trusts another. pkgTrustOf says how far a package is trusted (PkgTrustSystemE, PkgTrustUserE, PkgTrustNoneE). The rules the planner keeps: kpunch.* packages must be signed by a SYSTEM key; an upgrade must be signed by the key that signed the installed version (or a SYSTEM key); an unsigned package from a file is installed only after a warning.
+
+.h2 Planning (pkgPlan.h)
+
+An offer (PkgOfferT) is a package that can be installed: pkgOfferRead reads one file, pkgOffersFor reads a file and every package in its folder (what it may need), pkgOffersInFolder a whole folder. pkgPlanInstall(db, offers, count, wanted, wantedCount, &plan) plans installing the offers wanted with everything they need, in the order they go in, each checked (the DVX and ABI it needs, files another package has, a newer version installed, the signing rules); plan.err says why when it fails, plan.bytes is what it unpacks, and plan.stale lists the add-ons an update of DVX itself would leave unable to load. pkgPlanDependents lists the installed packages that need some going (they go too); pkgPlanOrphans those only they needed.
+
+.h2 Installing and Removing (pkgOp.h)
+
+pkgOpInstall(db, path, ring, explicitly) and pkgOpRemove(db, id, removeChanged) make an operation; pkgOpStep runs it a bounded piece at a time (PKG_OP_STEP bytes) until PkgOpDoneE or PkgOpFailedE (pkgOpMessage says why), with pkgOpProgress for a progress bar. Installing unpacks every file under a scratch name beside where it goes, checking each against the manifest, and only then swaps them all in, keeping the files they replace until every swap is done (and putting them back if one fails); then it removes what an older version had that this one does not, and writes the record. Removing deletes the files unchanged since they were installed (all of them with removeChanged), the folders that leaves empty, and the record; pkgOpKept lists the changed files it left. pkgOpFree before the swap cancels an install; after it, the operation is finished first.
+
+.h2 The Package Tool
+
+DVXPKG.EXE (DVX's SYSTEM folder) and bin/dvxpkg (the SDK, on the build machine) take the same commands:
+
+.code
+dvxpkg build TEMPLATE.INI FOLDER OUT.DPK [KEY.SEC]
+dvxpkg sign NAME.DPK KEY.SEC [-d]
+dvxpkg verify NAME.DPK [-s NAME.SIG] [KEY.KEY ...]
+dvxpkg info NAME.DPK
+dvxpkg keygen "Key name" BASE
+dvxpkg install NAME.DPK [-r DVXDIR] [-y]
+dvxpkg remove VENDOR.NAME [-r DVXDIR] [-f]
+dvxpkg list [-r DVXDIR]
+.endcode
+
+On DOS, DVXPKG remove is the way out when an add-on keeps DVX from starting (DVX /SAFE, or holding Shift, also starts DVX without add-ons). The SDK's make package builds NAME.dpk from a project (sdk.mk's PKG_* settings), and the IDE's File > Make Package from a BASIC project.
diff --git a/src/libs/kpunch/pkg/pkg.h b/src/libs/kpunch/pkg/pkg.h
new file mode 100644
index 00000000..022bca79
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkg.h
@@ -0,0 +1,52 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkg.h -- the package library: add-on packages for DVX
+//
+// pkgFormat.h the ZIP rules every DVX package keeps (and Setup's own
+// multi-disk form)
+// pkgZip.h reading a package front to back; writing one
+// pkgManifest.h PACKAGE.INI: what a package is, needs, and holds
+// pkgSign.h keys and Ed25519 signatures over a manifest
+// pkgDpk.h package files (.DPK): build, read, check, sign
+// pkgTrust.h the keys DVX trusts, and how far it trusts a package
+// pkgDb.h the packages installed in a DVX folder
+// pkgPlan.h what installing or removing packages takes
+// pkgOp.h installing and removing one, a step at a time
+//
+// The library is LIBS\KPUNCH\PKG\PKG.LIB; the package tool (dvxpkg on the
+// build machine, DVXPKG.EXE in DVX) and Setup use the same sources.
+
+#ifndef PKG_H
+#define PKG_H
+
+#include "pkgFormat.h"
+#include "pkgZip.h"
+#include "pkgManifest.h"
+#include "pkgSign.h"
+#include "pkgDpk.h"
+#include "pkgTrust.h"
+#include "pkgDb.h"
+#include "pkgPlan.h"
+#include "pkgOp.h"
+
+#endif // PKG_H
diff --git a/src/libs/kpunch/pkg/pkgBas.c b/src/libs/kpunch/pkg/pkgBas.c
new file mode 100644
index 00000000..9c1d35a2
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgBas.c
@@ -0,0 +1,1257 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// pkgBas.c -- pkg.bas: add-on packages for DVX BASIC programs
+//
+// A session (PkgOpen) holds the packages installed in the DVX folder, the
+// keys DVX trusts, the packages read from a folder (offers), the last
+// install plan, and one running job (installing the plan, or removing
+// packages). Lists come back as ListView rows: cells separated by TAB,
+// each row ending in LF. Making packages (PkgBuild, PkgSign, PkgKeygen)
+// needs a session only for PkgError$.
+
+#include "pkgBas.h"
+#include "pkg.h"
+#include "dvxBas.h"
+#include "dvxPlat.h"
+#include "monocypher.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+
+#define ROW_ENDS "\n\r" // what may end a row PkgBuild is given
+#define ID_SEP "\n" // between the ids a function takes or gives
+#define OFFER_SEP " ," // between PkgPlan's offer numbers
+#define NUMBER_TEXT_MAX 24
+#define PERCENT_MAX 100
+#define YES_TEXT "yes"
+#define NO_TEXT "no"
+#define PKG_SEP '/' // between the parts of a name in a package
+
+typedef enum {
+ JobNoneE,
+ JobInstallE,
+ JobRemoveE
+} JobE;
+
+typedef struct {
+ PkgDbT db;
+ PkgKeyringT ring;
+ PkgOfferT *offers; // stb_ds
+ PkgPlanT plan;
+ bool planned;
+ char *staleRows; // the plan's stale add-ons, as rows (malloc'd)
+ JobE job;
+ PkgOpT *op;
+ int32_t jobAt; // the plan step or removal running
+ char **removeIds; // stb_ds of strdup'd ids
+ bool removeChanged;
+ char **kept; // stb_ds of strdup'd names: changed files left
+ uint32_t doneBefore; // bytes of the operations finished
+ uint32_t jobBytes; // bytes of the whole job (estimated for removal)
+ PkgRunE result;
+ char current[PKG_TEXT_MAX];
+ char err[PKG_ERR_MAX];
+} SessionT;
+
+// ============================================================
+// Prototypes
+// ============================================================
+static void addIdRow(const PkgManifestT *m);
+static const char *fieldOf(const SessionT *ss, const PkgManifestT *m, int32_t field, const PkgInstalledT *inst, const PkgOfferT *offer);
+static uint32_t filesBytes(const PkgManifestT *m);
+static bool filesParse(SessionT *ss, const char *rows, PkgDpkFileT **files);
+static void idsFree(char **ids);
+static char **idsSplit(const char *text);
+static void jobEnd(SessionT *ss, PkgRunE result);
+static bool jobNext(SessionT *ss);
+static char *nameCopy(const char *text, size_t len);
+static const PkgOfferT *offerOf(const SessionT *ss, int32_t offer);
+static void offersFree(SessionT *ss);
+int32_t PkgBuild(int32_t s, const char *templatePath, const char *files, const char *outPath, const char *secretPath);
+void PkgClose(int32_t s);
+const char *PkgDependents(int32_t s, const char *ids);
+const char *PkgDependsRows(int32_t s, const char *id);
+int32_t PkgDvxAbi(void);
+const char *PkgDvxVersion(void);
+const char *PkgError(int32_t s);
+const char *PkgField(int32_t s, const char *id, int32_t field);
+const char *PkgFilesRows(int32_t s, const char *id);
+const char *PkgFolderRows(int32_t s, const char *folder, const char *prefix);
+const char *PkgIdPart(const char *text);
+int32_t PkgIdValid(const char *id);
+const char *PkgInstalledRows(int32_t s);
+int32_t PkgInstallStart(int32_t s);
+int32_t PkgKeygen(int32_t s, const char *keyName, const char *basePath);
+const char *PkgKeyInfo(int32_t s, const char *path);
+const char *PkgKindFolder(const char *kind, const char *id);
+const char *PkgKindName(int32_t kind);
+const char *PkgNeededBy(int32_t s, const char *id);
+int32_t PkgOfferCount(int32_t s);
+const char *PkgOfferDependsRows(int32_t s, int32_t offer);
+const char *PkgOfferField(int32_t s, int32_t offer, int32_t field);
+int32_t PkgOfferFile(int32_t s, const char *path);
+const char *PkgOfferFilesRows(int32_t s, int32_t offer);
+int32_t PkgOfferFolder(int32_t s, const char *dir);
+int32_t PkgOfferIndex(int32_t s, const char *id);
+int32_t PkgOpen(void);
+const char *PkgOrphans(int32_t s, const char *ids);
+int32_t PkgPlan(int32_t s, const char *offers);
+double PkgPlanBytes(int32_t s);
+int32_t PkgPlanRestart(int32_t s);
+const char *PkgPlanRows(int32_t s);
+const char *PkgPlanStale(int32_t s);
+int32_t PkgRemoveStart(int32_t s, const char *ids, int32_t removeChanged);
+void PkgRunCancel(int32_t s);
+const char *PkgRunCurrent(int32_t s);
+int32_t PkgRunFor(int32_t s, int32_t milliseconds);
+const char *PkgRunKept(int32_t s);
+const char *PkgRunMessage(int32_t s);
+int32_t PkgRunPercent(int32_t s);
+int32_t PkgSign(int32_t s, const char *dpkPath, const char *secretPath, int32_t detached);
+int32_t PkgTrustKey(int32_t s, const char *keyPath);
+int32_t PkgVersionValid(const char *text);
+static void planForget(SessionT *ss);
+static bool restartKind(PkgKindE kind);
+static int32_t resultOf(SessionT *ss, bool ok);
+static void rowsDepends(const SessionT *ss, const PkgManifestT *m);
+static void rowsFiles(const PkgManifestT *m);
+static void rowsPackages(const PkgInstalledT *const *pkgs);
+static void sessionFree(void *obj);
+static SessionT *sessionOf(int32_t s);
+static const PkgKeyT *signerKey(const SessionT *ss, const uint8_t *id);
+static char *textCopy(const char *text, size_t len);
+
+// ============================================================
+// Module state
+// ============================================================
+
+static BasHandlesT sSessions = BAS_HANDLES(sessionFree, 0); // the open sessions
+static BasTextT sText; // the last string result
+
+
+// A package's id and name as a row.
+static void addIdRow(const PkgManifestT *m) {
+ const char *cells[] = { m->id, m->name };
+
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+}
+
+
+
+// A row of cells onto the result being built: TAB between them, LF after
+// (a TAB or line end in a cell becomes a blank).
+// One field of m as text (in a buffer of the caller's sText): inst is its
+// installed record, offer the offer it is (one of them, or neither).
+static const char *fieldOf(const SessionT *ss, const PkgManifestT *m, int32_t field, const PkgInstalledT *inst, const PkgOfferT *offer) {
+ static const char *const trustNames[] = { [PkgTrustNoneE] = "none", [PkgTrustUserE] = "user", [PkgTrustSystemE] = "system" };
+ static const PkgVersionT any = { 0, 0, 0 };
+ char text[PKG_TEXT_MAX];
+ const uint8_t *signer = NULL;
+ const PkgKeyT *key;
+
+ text[0] = '\0';
+
+ if (inst && inst->hasSigner) {
+ signer = inst->signer;
+ } else if (offer && offer->hasNamed) {
+ signer = offer->named;
+ }
+
+ switch (field) {
+ case PkgFieldIdE:
+ return basTextSet(&sText, m->id);
+
+ case PkgFieldNameE:
+ return basTextSet(&sText, m->name);
+
+ case PkgFieldVersionE:
+ pkgVersionFormat(m->version, text, sizeof(text));
+ break;
+
+ case PkgFieldKindE:
+ return basTextSet(&sText, pkgKindName(m->kind));
+
+ case PkgFieldSummaryE:
+ return basTextSet(&sText, m->summary);
+
+ case PkgFieldPublisherE:
+ return basTextSet(&sText, m->publisher);
+
+ case PkgFieldAuthorE:
+ return basTextSet(&sText, m->author);
+
+ case PkgFieldLicenceE:
+ return basTextSet(&sText, m->licence);
+
+ case PkgFieldHomepageE:
+ return basTextSet(&sText, m->homepage);
+
+ case PkgFieldDvxE:
+ if (pkgVersionCompare(m->dvx, any) != 0) {
+ pkgVersionFormat(m->dvx, text, sizeof(text));
+ }
+
+ break;
+
+ case PkgFieldAbiE:
+ if (m->abi) {
+ snprintf(text, sizeof(text), "%ld", (long)m->abi);
+ }
+
+ break;
+
+ case PkgFieldSignerE:
+ if (signer) {
+ pkgKeyIdText(signer, text);
+ }
+
+ break;
+
+ case PkgFieldSignerNameE:
+ key = signer ? signerKey(ss, signer) : NULL;
+ return basTextSet(&sText, key ? key->name : "");
+
+ case PkgFieldExplicitE:
+ return basTextSet(&sText, inst ? (inst->explicitly ? YES_TEXT : NO_TEXT) : "");
+
+ case PkgFieldTrustE:
+ return basTextSet(&sText, offer ? trustNames[offer->trust] : "");
+
+ case PkgFieldWhereE:
+ return basTextSet(&sText, offer ? offer->where : "");
+
+ case PkgFieldSizeE:
+ snprintf(text, sizeof(text), "%lu", (unsigned long)filesBytes(m));
+ break;
+
+ default:
+ break;
+ }
+
+ return basTextSet(&sText, text);
+}
+
+
+static uint32_t filesBytes(const PkgManifestT *m) {
+ uint32_t bytes = 0;
+
+ for (int32_t f = 0; f < (int32_t)arrlen(m->files); f++) {
+ bytes += m->files[f].size;
+ }
+
+ return bytes;
+}
+
+
+// PkgBuild's files: rows of name TAB file (blank rows skipped) onto *files
+// (pkgDpkFilesFree). False with the reason in ss->err.
+static bool filesParse(SessionT *ss, const char *rows, PkgDpkFileT **files) {
+ const char *p = rows ? rows : "";
+ int32_t row = 0;
+
+ for (p += strspn(p, ROW_ENDS); *p; p += strspn(p, ROW_ENDS)) {
+ size_t len = strcspn(p, ROW_ENDS);
+ const char *tab = (const char *)memchr(p, BAS_ROW_CELL_SEP, len);
+ PkgDpkFileT f = { NULL, NULL };
+
+ row++;
+
+ if (!tab) {
+ snprintf(ss->err, sizeof(ss->err), "Row %d of the files has no TAB between the name and the file.", (int)row);
+ return false;
+ }
+
+ f.name = nameCopy(p, (size_t)(tab - p));
+ f.source = textCopy(tab + 1, len - (size_t)(tab + 1 - p));
+
+ if (!f.name || !f.source) {
+ free(f.name);
+ free(f.source);
+ snprintf(ss->err, sizeof(ss->err), "There is not enough memory.");
+ return false;
+ }
+
+ arrput(*files, f);
+ p += len;
+ }
+
+ return true;
+}
+
+
+static void idsFree(char **ids) {
+ for (int32_t i = 0; i < (int32_t)arrlen(ids); i++) {
+ free(ids[i]);
+ }
+
+ arrfree(ids);
+}
+
+
+// The ids in text (separated by line ends, blanks, or commas), each
+// copied: an stb_ds array (idsFree).
+static char **idsSplit(const char *text) {
+ char **ids = NULL;
+ const char *p = text ? text : "";
+
+ while (*p) {
+ size_t len = strcspn(p, "\r\n\t ,");
+
+ char *id = (len > 0 && len < PKG_ID_MAX) ? textCopy(p, len) : NULL;
+
+ if (id) {
+ arrput(ids, id);
+ }
+
+ p += len;
+ p += strspn(p, "\r\n\t ,");
+ }
+
+ return ids;
+}
+
+
+// The job is over (done, or failed with ss->err said): an operation still
+// open is stopped (or finished, once its files are in), and an install's
+// plan is used up.
+static void jobEnd(SessionT *ss, PkgRunE result) {
+ pkgOpFree(ss->op);
+ ss->op = NULL;
+
+ if (ss->job == JobInstallE) {
+ planForget(ss);
+ }
+
+ idsFree(ss->removeIds);
+ ss->removeIds = NULL;
+ ss->job = JobNoneE;
+ ss->result = result;
+}
+
+
+// Start the job's next operation; false when there is none (ss->err says
+// why when it could not start).
+static bool jobNext(SessionT *ss) {
+ const char *name = NULL;
+
+ if (ss->job == JobInstallE && ss->jobAt < (int32_t)arrlen(ss->plan.steps)) {
+ const PkgStepT *st = &ss->plan.steps[ss->jobAt];
+ const PkgOfferT *o = &ss->offers[st->offer];
+
+ name = o->manifest.name;
+ ss->op = pkgOpInstall(&ss->db, o->where, &ss->ring, st->explicitly);
+ } else if (ss->job == JobRemoveE && ss->jobAt < (int32_t)arrlen(ss->removeIds)) {
+ const PkgInstalledT *inst = pkgDbFind(&ss->db, ss->removeIds[ss->jobAt]);
+
+ name = inst ? inst->manifest.name : ss->removeIds[ss->jobAt];
+ ss->op = pkgOpRemove(&ss->db, ss->removeIds[ss->jobAt], ss->removeChanged);
+ }
+
+ if (!name) {
+ return false;
+ }
+
+ snprintf(ss->current, sizeof(ss->current), "%s", name);
+
+ if (!ss->op) {
+ snprintf(ss->err, sizeof(ss->err), "There is not enough memory.");
+ }
+
+ return ss->op != NULL;
+}
+
+
+// A name in a package as a program may spell it (len bytes of text),
+// copied (malloc'd) with '\\' made '/' and a trailing '/' dropped. NULL
+// when out of memory.
+static char *nameCopy(const char *text, size_t len) {
+ char *copy = textCopy(text, len);
+
+ if (!copy) {
+ return NULL;
+ }
+
+ for (char *c = copy; *c; c++) {
+ *c = platformPathIsSep(*c) ? PKG_SEP : *c;
+ }
+
+ while (len > 0 && copy[len - 1] == PKG_SEP) {
+ copy[--len] = '\0';
+ }
+
+ return copy;
+}
+
+
+static const PkgOfferT *offerOf(const SessionT *ss, int32_t offer) {
+ return (ss && offer >= 0 && offer < (int32_t)arrlen(ss->offers)) ? &ss->offers[offer] : NULL;
+}
+
+
+static void offersFree(SessionT *ss) {
+ pkgOffersFree(ss->offers);
+ ss->offers = NULL;
+}
+
+
+// Build the package outPath from the template templatePath
+// (pkgTemplateRead; code with no abi gets this DVX's) and files: rows of
+// a name in the package (from the DVX folder) TAB the file it is made
+// from (PkgFolderRows$). Signed with the secret key file secretPath
+// unless it is "". True, or False with the reason in PkgError$.
+int32_t PkgBuild(int32_t s, const char *templatePath, const char *files, const char *outPath, const char *secretPath) {
+ SessionT *ss = sessionOf(s);
+ PkgDpkFileT *list = NULL;
+ bool keyed = secretPath && secretPath[0];
+ PkgManifestT templ;
+ PkgSecretT secret;
+ bool ok;
+
+ if (!ss || !templatePath || !outPath || !pkgTemplateRead(templatePath, &templ, ss->err, sizeof(ss->err))) {
+ return BAS_FALSE;
+ }
+
+ ok = filesParse(ss, files, &list) && (!keyed || pkgSecretRead(secretPath, &secret, ss->err, sizeof(ss->err)));
+ ok = ok && pkgDpkBuildFiles(&templ, list, (int32_t)arrlen(list), outPath, keyed ? &secret : NULL, ss->err, sizeof(ss->err));
+ pkgSecretWipe(&secret);
+ pkgDpkFilesFree(list);
+ pkgManifestFree(&templ);
+ return resultOf(ss, ok);
+}
+
+
+// Close the session (a running job stops, or finishes once its files
+// are in).
+void PkgClose(int32_t s) {
+ basHandleFree(&sSessions, s);
+}
+
+
+// The installed packages that need any of ids (LF between them), and
+// those that need them: they go too. Rows: id, name; first to go first.
+const char *PkgDependents(int32_t s, const char *ids) {
+ SessionT *ss = sessionOf(s);
+ char **list = idsSplit(ids);
+ const PkgInstalledT **deps = ss ? pkgPlanDependents(&ss->db, (const char *const *)list, (int32_t)arrlen(list)) : NULL;
+
+ basTextStart(&sText);
+ rowsPackages(deps);
+ arrfree(deps);
+ idsFree(list);
+ return basTextEnd(&sText);
+}
+
+
+// What the installed package id needs. Rows: id, the oldest version that
+// will do, the version installed ("" when none).
+const char *PkgDependsRows(int32_t s, const char *id) {
+ SessionT *ss = sessionOf(s);
+ const PkgInstalledT *inst = ss ? pkgDbFind(&ss->db, id ? id : "") : NULL;
+
+ basTextStart(&sText);
+
+ if (inst) {
+ rowsDepends(ss, &inst->manifest);
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// The ABI this DVX's programs, libraries, and widgets are made for (a
+// manifest's abi).
+int32_t PkgDvxAbi(void) {
+ return DVX_ABI_VERSION;
+}
+
+
+// This DVX's version (major.minor.patch: a manifest's dvx).
+const char *PkgDvxVersion(void) {
+ return basTextSet(&sText, DVX_VERSION_TEXT);
+}
+
+
+const char *PkgError(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ return basTextSet(&sText, ss ? ss->err : "");
+}
+
+
+// A field (PKG_FIELD_*) of the installed package id ("" when it is not
+// installed).
+const char *PkgField(int32_t s, const char *id, int32_t field) {
+ SessionT *ss = sessionOf(s);
+ const PkgInstalledT *inst = ss ? pkgDbFind(&ss->db, id ? id : "") : NULL;
+
+ return inst ? fieldOf(ss, &inst->manifest, field, inst, NULL) : basTextSet(&sText, "");
+}
+
+
+// The files of the installed package id. Rows: name, size.
+const char *PkgFilesRows(int32_t s, const char *id) {
+ SessionT *ss = sessionOf(s);
+ const PkgInstalledT *inst = ss ? pkgDbFind(&ss->db, id ? id : "") : NULL;
+
+ basTextStart(&sText);
+
+ if (inst) {
+ rowsFiles(&inst->manifest);
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// Every file under folder (but a PACKAGE.INI or PACKAGE.SIG at its top) as
+// PkgBuild's files: rows of its name in the package (prefix, then its name
+// under folder; from folder itself when prefix is "") and the file, in
+// name order. "" with the reason in PkgError$ when folder cannot be read.
+const char *PkgFolderRows(int32_t s, const char *folder, const char *prefix) {
+ SessionT *ss = sessionOf(s);
+ char *pre = nameCopy(prefix ? prefix : "", prefix ? strlen(prefix) : 0);
+ PkgDpkFileT *files = NULL;
+ bool ok = ss && folder && pre && pkgDpkFolderFiles(folder, pre, &files);
+
+ basTextStart(&sText);
+
+ for (int32_t i = 0; ok && i < (int32_t)arrlen(files); i++) {
+ const char *cells[] = { platformPathNative(files[i].name), files[i].source };
+
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ }
+
+ if (ss && !ok) {
+ snprintf(ss->err, sizeof(ss->err), "Cannot read the folder %s.", folder ? folder : "");
+ }
+
+ resultOf(ss, ok);
+ pkgDpkFilesFree(files);
+ free(pre);
+ return ok ? basTextEnd(&sText) : basTextSet(&sText, "");
+}
+
+
+// A part of a package id made from text (a publisher's or a program's
+// name): its letters, digits, and _ in lower case, at most 8.
+const char *PkgIdPart(const char *text) {
+ char part[PKG_ID_PART_MAX + 1];
+
+ pkgIdPartFrom(text, part);
+ return basTextSet(&sText, part);
+}
+
+
+// True when id is a package id: vendor.name, each part 1 to 8 of a-z,
+// 0-9, and _.
+int32_t PkgIdValid(const char *id) {
+ return (id && pkgIdValid(id)) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+// Every installed package. Rows: id, name, version, kind, summary.
+const char *PkgInstalledRows(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ basTextStart(&sText);
+
+ for (int32_t i = 0; ss && i < (int32_t)arrlen(ss->db.pkgs); i++) {
+ const PkgManifestT *m = &ss->db.pkgs[i].manifest;
+ char version[PKG_VERSION_MAX];
+ const char *cells[] = { m->id, m->name, version, pkgKindName(m->kind), m->summary };
+
+ pkgVersionFormat(m->version, version, sizeof(version));
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// Start installing the plan (PkgPlan): run it with PkgRunFor. False when
+// there is no plan or a job is running.
+int32_t PkgInstallStart(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ if (!ss || !ss->planned || ss->job != JobNoneE) {
+ return BAS_FALSE;
+ }
+
+ idsFree(ss->kept);
+ ss->kept = NULL;
+ ss->err[0] = '\0';
+ ss->job = JobInstallE;
+ ss->jobAt = 0;
+ ss->doneBefore = 0;
+ ss->jobBytes = ss->plan.bytes;
+ ss->result = PkgRunBusyE;
+
+ if (!jobNext(ss)) {
+ jobEnd(ss, ss->err[0] ? PkgRunFailedE : PkgRunDoneE);
+ }
+
+ return BAS_TRUE;
+}
+
+
+// Make a key pair named keyName: basePath.KEY (the public key, to give
+// out) and basePath.SEC (the secret key, to keep private). True, or False
+// with the reason in PkgError$.
+int32_t PkgKeygen(int32_t s, const char *keyName, const char *basePath) {
+ SessionT *ss = sessionOf(s);
+ uint8_t seed[PKG_SEED_SIZE];
+ bool ok;
+
+ if (!ss || !keyName || !basePath) {
+ return BAS_FALSE;
+ }
+
+ ok = pkgRandomBytes(seed, PKG_SEED_SIZE);
+
+ if (!ok) {
+ snprintf(ss->err, sizeof(ss->err), "There is nothing random enough to make a key from.");
+ }
+
+ ok = ok && pkgKeyWrite(seed, keyName, basePath, NULL, ss->err, sizeof(ss->err));
+ crypto_wipe(seed, sizeof(seed));
+ return resultOf(ss, ok);
+}
+
+
+// The name and id of the key in the key file (NAME.KEY) or secret key file
+// (NAME.SEC) at path, as a row; "" with the reason in PkgError$ when it is
+// not one.
+const char *PkgKeyInfo(int32_t s, const char *path) {
+ SessionT *ss = sessionOf(s);
+ PkgKeyT key;
+ PkgSecretT secret;
+ char id[PKG_KEY_ID_TEXT];
+ const char *cells[] = { key.name, id };
+ bool ok = false;
+
+ if (ss && path && dvxHasExt(path, PKG_SECRET_EXT)) {
+ ok = pkgSecretRead(path, &secret, ss->err, sizeof(ss->err));
+
+ if (ok) {
+ memcpy(key.name, secret.name, sizeof(key.name));
+ memcpy(key.id, secret.id, sizeof(key.id));
+ }
+
+ pkgSecretWipe(&secret);
+ } else if (ss && path) {
+ ok = pkgKeyRead(path, &key, ss->err, sizeof(ss->err));
+ }
+
+ if (!ok) {
+ return basTextSet(&sText, "");
+ }
+
+ pkgKeyIdText(key.id, id);
+ resultOf(ss, ok);
+ basTextStart(&sText);
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ return basTextEnd(&sText);
+}
+
+
+// The folder a package id of kind ("app", ...) keeps its files in, from
+// the DVX folder (APPS\\VENDOR\\NAME for a program); "" for bundle and
+// system (no folder of their own), or a bad kind or id.
+const char *PkgKindFolder(const char *kind, const char *id) {
+ char folder[PKG_NAME_MAX];
+ PkgKindE k;
+
+ if (!kind || !id || !pkgKindParse(kind, &k) || !pkgKindFolder(k, id, folder, sizeof(folder))) {
+ return basTextSet(&sText, "");
+ }
+
+ return basTextSet(&sText, platformPathNative(folder));
+}
+
+
+// The name of kind number kind, from 0 ("app"); "" past the last.
+const char *PkgKindName(int32_t kind) {
+ return basTextSet(&sText, (kind >= 0 && kind < PkgKindCountE) ? pkgKindName((PkgKindE)kind) : "");
+}
+
+
+// The installed packages that need id themselves. Rows: id, name.
+const char *PkgNeededBy(int32_t s, const char *id) {
+ SessionT *ss = sessionOf(s);
+
+ basTextStart(&sText);
+
+ for (int32_t i = 0; ss && id && i < (int32_t)arrlen(ss->db.pkgs); i++) {
+ const PkgManifestT *m = &ss->db.pkgs[i].manifest;
+
+ for (int32_t d = 0; d < (int32_t)arrlen(m->depends); d++) {
+ if (strcasecmp(m->depends[d].id, id) == 0) {
+ addIdRow(m);
+ break;
+ }
+ }
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+int32_t PkgOfferCount(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ return ss ? (int32_t)arrlen(ss->offers) : 0;
+}
+
+
+// What offer needs (the rows of PkgDependsRows).
+const char *PkgOfferDependsRows(int32_t s, int32_t offer) {
+ const PkgOfferT *o = offerOf(sessionOf(s), offer);
+
+ basTextStart(&sText);
+
+ if (o) {
+ rowsDepends(sessionOf(s), &o->manifest);
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// A field (PKG_FIELD_*) of offer ("" for a bad number).
+const char *PkgOfferField(int32_t s, int32_t offer, int32_t field) {
+ SessionT *ss = sessionOf(s);
+ const PkgOfferT *o = offerOf(ss, offer);
+
+ return o ? fieldOf(ss, &o->manifest, field, NULL, o) : basTextSet(&sText, "");
+}
+
+
+// Read the package at path, and every other package in its folder (what
+// it may need), as the offers (the ones before are gone, and the plan).
+// The number of path's offer (0), or -1: PkgError$ says why.
+int32_t PkgOfferFile(int32_t s, const char *path) {
+ SessionT *ss = sessionOf(s);
+
+ // A running install reads the plan's offers
+ if (!ss || !path || ss->job != JobNoneE) {
+ return -1;
+ }
+
+ offersFree(ss);
+ planForget(ss);
+ ss->offers = pkgOffersFor(path, &ss->ring, ss->err, sizeof(ss->err));
+ return ss->offers ? 0 : -1;
+}
+
+
+// The files of offer (the rows of PkgFilesRows).
+const char *PkgOfferFilesRows(int32_t s, int32_t offer) {
+ const PkgOfferT *o = offerOf(sessionOf(s), offer);
+
+ basTextStart(&sText);
+
+ if (o) {
+ rowsFiles(&o->manifest);
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// Every package in dir as the offers: how many, or -1 while a job runs.
+int32_t PkgOfferFolder(int32_t s, const char *dir) {
+ SessionT *ss = sessionOf(s);
+
+ if (!ss || !dir || ss->job != JobNoneE) {
+ return -1;
+ }
+
+ offersFree(ss);
+ planForget(ss);
+ ss->offers = pkgOffersInFolder(dir, &ss->ring);
+ return (int32_t)arrlen(ss->offers);
+}
+
+
+// The newest offer of id, or -1.
+int32_t PkgOfferIndex(int32_t s, const char *id) {
+ const SessionT *ss = sessionOf(s);
+ int32_t best = -1;
+
+ for (int32_t i = 0; ss && id && i < (int32_t)arrlen(ss->offers); i++) {
+ const PkgManifestT *m = &ss->offers[i].manifest;
+
+ if (strcmp(m->id, id) == 0 && (best < 0 || pkgVersionCompare(m->version, ss->offers[best].manifest.version) > 0)) {
+ best = i;
+ }
+ }
+
+ return best;
+}
+
+
+// A session of the DVX folder's packages and the keys DVX trusts: its
+// handle, or 0 when out of memory.
+int32_t PkgOpen(void) {
+ SessionT *ss = (SessionT *)calloc(1, sizeof(SessionT));
+ int32_t handle;
+
+ if (!ss) {
+ return BAS_NO_HANDLE;
+ }
+
+ if (!pkgDbLoad(&ss->db, "")) {
+ pkgDbFree(&ss->db);
+ free(ss);
+ return BAS_NO_HANDLE;
+ }
+
+ pkgKeyringLoad(&ss->ring, "", platformUserDir());
+ ss->result = PkgRunDoneE;
+ handle = basHandleAdd(&sSessions, ss);
+
+ if (handle == BAS_NO_HANDLE) {
+ sessionFree(ss);
+ }
+
+ return handle;
+}
+
+
+// The installed packages that only ids needed (directly or through each
+// other), that were not chosen, and that nothing else needs. Rows: id,
+// name.
+const char *PkgOrphans(int32_t s, const char *ids) {
+ SessionT *ss = sessionOf(s);
+ char **list = idsSplit(ids);
+ const PkgInstalledT **orphans = ss ? pkgPlanOrphans(&ss->db, (const char *const *)list, (int32_t)arrlen(list)) : NULL;
+
+ basTextStart(&sText);
+ rowsPackages(orphans);
+ arrfree(orphans);
+ idsFree(list);
+ return basTextEnd(&sText);
+}
+
+
+// Plan installing the offers numbered in offers ("0 3"), with everything
+// they need: True, or False with the reason in PkgError$.
+int32_t PkgPlan(int32_t s, const char *offers) {
+ SessionT *ss = sessionOf(s);
+ int32_t *wanted = NULL;
+ const char *p = offers ? offers : "";
+ bool ok;
+
+ if (!ss || ss->job != JobNoneE) {
+ return BAS_FALSE;
+ }
+
+ planForget(ss);
+
+ while (*p) {
+ char *end = NULL;
+ long n = strtol(p, &end, 10);
+
+ if (end == p || n < 0 || n >= (long)arrlen(ss->offers)) {
+ snprintf(ss->err, sizeof(ss->err), "%s is not one of the packages read.", p);
+ arrfree(wanted);
+ return BAS_FALSE;
+ }
+
+ arrput(wanted, (int32_t)n);
+ p = end + strspn(end, OFFER_SEP);
+ }
+
+ ok = pkgPlanInstall(&ss->db, ss->offers, (int32_t)arrlen(ss->offers), wanted, (int32_t)arrlen(wanted), &ss->plan);
+ arrfree(wanted);
+
+ if (!ok) {
+ snprintf(ss->err, sizeof(ss->err), "%s", ss->plan.err);
+ return BAS_FALSE;
+ }
+
+ // The stale add-ons as rows now: the records may move once installing
+ basTextStart(&sText);
+ rowsPackages(ss->plan.stale);
+ ss->staleRows = strdup(basTextEnd(&sText));
+ ss->planned = true;
+ ss->err[0] = '\0';
+ return BAS_TRUE;
+}
+
+
+// The bytes the plan unpacks.
+double PkgPlanBytes(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ return (ss && ss->planned) ? (double)ss->plan.bytes : 0.0;
+}
+
+
+// True when the plan installs a library, a widget, or DVX itself: they are
+// loaded when DVX starts.
+int32_t PkgPlanRestart(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ for (int32_t i = 0; ss && ss->planned && i < (int32_t)arrlen(ss->plan.steps); i++) {
+ if (restartKind(ss->offers[ss->plan.steps[i].offer].manifest.kind)) {
+ return BAS_TRUE;
+ }
+ }
+
+ return BAS_FALSE;
+}
+
+
+// The plan, in the order it installs. Rows: id, name, version, action
+// (install, upgrade, reinstall), chosen (yes / no: needed by one that
+// is), trust (system, user, none).
+const char *PkgPlanRows(int32_t s) {
+ static const char *const actNames[] = { [PkgActInstallE] = "install", [PkgActUpgradeE] = "upgrade", [PkgActReinstallE] = "reinstall" };
+ static const char *const trustNames[] = { [PkgTrustNoneE] = "none", [PkgTrustUserE] = "user", [PkgTrustSystemE] = "system" };
+ SessionT *ss = sessionOf(s);
+
+ basTextStart(&sText);
+
+ for (int32_t i = 0; ss && ss->planned && i < (int32_t)arrlen(ss->plan.steps); i++) {
+ const PkgStepT *st = &ss->plan.steps[i];
+ const PkgOfferT *o = &ss->offers[st->offer];
+ const PkgManifestT *m = &o->manifest;
+ char version[PKG_VERSION_MAX];
+ const char *cells[] = { m->id, m->name, version, actNames[st->act], st->explicitly ? YES_TEXT : NO_TEXT, trustNames[o->trust] };
+
+ pkgVersionFormat(m->version, version, sizeof(version));
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// The installed add-ons a DVX update in the plan leaves unable to load
+// (made for the old DVX). Rows: id, name.
+const char *PkgPlanStale(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ return basTextSet(&sText, (ss && ss->staleRows) ? ss->staleRows : "");
+}
+
+
+// Start removing the installed packages ids, in that order (what needs
+// them first: PkgDependents$); changed files stay unless removeChanged.
+// False when a job is running.
+int32_t PkgRemoveStart(int32_t s, const char *ids, int32_t removeChanged) {
+ SessionT *ss = sessionOf(s);
+
+ if (!ss || ss->job != JobNoneE) {
+ return BAS_FALSE;
+ }
+
+ idsFree(ss->kept);
+ ss->kept = NULL;
+ ss->err[0] = '\0';
+ ss->removeIds = idsSplit(ids);
+ ss->removeChanged = removeChanged != BAS_FALSE;
+ ss->job = JobRemoveE;
+ ss->jobAt = 0;
+ ss->doneBefore = 0;
+ ss->jobBytes = 0;
+ ss->result = PkgRunBusyE;
+
+ for (int32_t i = 0; i < (int32_t)arrlen(ss->removeIds); i++) {
+ const PkgInstalledT *inst = pkgDbFind(&ss->db, ss->removeIds[i]);
+
+ ss->jobBytes += inst ? filesBytes(&inst->manifest) : 0;
+ }
+
+ if (!jobNext(ss)) {
+ jobEnd(ss, ss->err[0] ? PkgRunFailedE : PkgRunDoneE);
+ }
+
+ return BAS_TRUE;
+}
+
+
+// Stop the job after the package it is on (one being unpacked is left as
+// it was; one whose files are in is finished).
+void PkgRunCancel(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ if (ss && ss->job != JobNoneE) {
+ snprintf(ss->err, sizeof(ss->err), "Stopped before it was done.");
+ jobEnd(ss, PkgRunFailedE);
+ }
+}
+
+
+// The name of the package the job is on.
+const char *PkgRunCurrent(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ return basTextSet(&sText, ss ? ss->current : "");
+}
+
+
+// Run the job for up to milliseconds (at least one step): PKG_RUN_BUSY,
+// PKG_RUN_DONE, or PKG_RUN_FAILED (PkgRunMessage$ says why). With no job
+// running, how the last one ended.
+int32_t PkgRunFor(int32_t s, int32_t milliseconds) {
+ SessionT *ss = sessionOf(s);
+ PlatformTicksT end = platformClock() + PLATFORM_MS_TO_TICKS(milliseconds > 0 ? milliseconds : 0);
+
+ if (!ss) {
+ return PkgRunFailedE;
+ }
+
+ while (ss->job != JobNoneE) {
+ PkgOpStateE st = pkgOpStep(ss->op);
+ uint32_t done;
+ uint32_t total;
+
+ if (st == PkgOpFailedE) {
+ snprintf(ss->err, sizeof(ss->err), "%s: %s", ss->current, pkgOpMessage(ss->op));
+ jobEnd(ss, PkgRunFailedE);
+ } else if (st == PkgOpDoneE) {
+ char *const *kept = pkgOpKept(ss->op);
+
+ for (int32_t i = 0; i < (int32_t)arrlen(kept); i++) {
+ char *copy = strdup(kept[i]);
+
+ if (copy) {
+ arrput(ss->kept, copy);
+ }
+ }
+
+ pkgOpProgress(ss->op, &done, &total);
+ ss->doneBefore += total;
+ pkgOpFree(ss->op);
+ ss->op = NULL;
+ ss->jobAt++;
+
+ if (!jobNext(ss)) {
+ jobEnd(ss, ss->err[0] ? PkgRunFailedE : PkgRunDoneE);
+ }
+ }
+
+ if (platformClock() >= end) {
+ break;
+ }
+ }
+
+ return ss->job != JobNoneE ? PkgRunBusyE : ss->result;
+}
+
+
+// The files the job left because they changed since they were installed
+// (LF between them).
+const char *PkgRunKept(int32_t s) {
+ SessionT *ss = sessionOf(s);
+
+ basTextStart(&sText);
+
+ for (int32_t i = 0; ss && i < (int32_t)arrlen(ss->kept); i++) {
+ basTextAppend(&sText, ss->kept[i]);
+ basTextAppend(&sText, ID_SEP);
+ }
+
+ return basTextEnd(&sText);
+}
+
+
+// Why the job failed ("" when it did not).
+const char *PkgRunMessage(int32_t s) {
+ return PkgError(s);
+}
+
+
+// How far the job is, 0 to 100 (100 once it is done).
+int32_t PkgRunPercent(int32_t s) {
+ SessionT *ss = sessionOf(s);
+ uint32_t done = 0;
+ uint32_t total = 0;
+
+ if (!ss) {
+ return 0;
+ }
+
+ if (ss->job == JobNoneE) {
+ return ss->result == PkgRunDoneE ? PERCENT_MAX : 0;
+ }
+
+ if (ss->op) {
+ pkgOpProgress(ss->op, &done, &total);
+ }
+
+ if (ss->jobBytes == 0) {
+ return 0;
+ }
+
+ return (int32_t)(((uint64_t)(ss->doneBefore + done) * PERCENT_MAX) / ss->jobBytes);
+}
+
+
+// Sign the package dpkPath with the secret key file secretPath: into it,
+// or beside it (NAME.SIG) when detached. True, or False with the reason
+// in PkgError$.
+int32_t PkgSign(int32_t s, const char *dpkPath, const char *secretPath, int32_t detached) {
+ SessionT *ss = sessionOf(s);
+ PkgSecretT secret;
+ bool ok;
+
+ if (!ss || !dpkPath || !secretPath) {
+ return BAS_FALSE;
+ }
+
+ ok = pkgSecretRead(secretPath, &secret, ss->err, sizeof(ss->err)) && pkgDpkSign(dpkPath, &secret, detached != BAS_FALSE, ss->err, sizeof(ss->err));
+ pkgSecretWipe(&secret);
+ return resultOf(ss, ok);
+}
+
+
+// Trust the key file at keyPath from now on (copied to the user's KEYS
+// folder). Offers read before keep the trust they had: read them again.
+int32_t PkgTrustKey(int32_t s, const char *keyPath) {
+ SessionT *ss = sessionOf(s);
+
+ return resultOf(ss, ss && keyPath && pkgKeyringAdd(&ss->ring, platformUserDir(), keyPath, ss->err, sizeof(ss->err)));
+}
+
+
+// True when text is a version: major, major.minor, or major.minor.patch,
+// each 0 to 65535.
+int32_t PkgVersionValid(const char *text) {
+ PkgVersionT v;
+
+ return (text && pkgVersionParse(text, &v)) ? BAS_TRUE : BAS_FALSE;
+}
+
+
+static void planForget(SessionT *ss) {
+ pkgPlanFree(&ss->plan);
+ free(ss->staleRows);
+ ss->staleRows = NULL;
+ ss->planned = false;
+}
+
+
+// Kinds loaded when DVX starts.
+static bool restartKind(PkgKindE kind) {
+ return kind == PkgKindLibraryE || kind == PkgKindWidgetE || kind == PkgKindSystemE;
+}
+
+
+// BASIC's True (PkgError$ cleared) or False (it says why) for ok.
+static int32_t resultOf(SessionT *ss, bool ok) {
+ if (ss && ok) {
+ ss->err[0] = '\0';
+ }
+
+ return ok ? BAS_TRUE : BAS_FALSE;
+}
+
+
+static void rowsDepends(const SessionT *ss, const PkgManifestT *m) {
+ for (int32_t d = 0; d < (int32_t)arrlen(m->depends); d++) {
+ const PkgDependT *dep = &m->depends[d];
+ const PkgInstalledT *inst = ss ? pkgDbFind(&ss->db, dep->id) : NULL;
+ char min[PKG_VERSION_MAX];
+ char have[PKG_VERSION_MAX];
+ const char *cells[] = { dep->id, min, have };
+
+ pkgVersionFormat(dep->min, min, sizeof(min));
+ have[0] = '\0';
+
+ if (inst) {
+ pkgVersionFormat(inst->manifest.version, have, sizeof(have));
+ }
+
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ }
+}
+
+
+static void rowsFiles(const PkgManifestT *m) {
+ for (int32_t f = 0; f < (int32_t)arrlen(m->files); f++) {
+ char size[NUMBER_TEXT_MAX];
+ const char *cells[] = { m->files[f].name, size };
+
+ snprintf(size, sizeof(size), "%lu", (unsigned long)m->files[f].size);
+ basTextRow(&sText, cells, DVX_ARRAY_LEN(cells));
+ }
+}
+
+
+static void rowsPackages(const PkgInstalledT *const *pkgs) {
+ for (int32_t i = 0; i < (int32_t)arrlen(pkgs); i++) {
+ addIdRow(&pkgs[i]->manifest);
+ }
+}
+
+
+// A session (the handles' freeFn).
+static void sessionFree(void *obj) {
+ SessionT *ss = (SessionT *)obj;
+
+ jobEnd(ss, PkgRunFailedE);
+ planForget(ss);
+ offersFree(ss);
+ idsFree(ss->kept);
+ pkgKeyringFree(&ss->ring);
+ pkgDbFree(&ss->db);
+ free(ss);
+}
+
+
+static SessionT *sessionOf(int32_t s) {
+ return (SessionT *)basHandleGet(&sSessions, s);
+}
+
+
+// The key DVX trusts with id, or NULL.
+static const PkgKeyT *signerKey(const SessionT *ss, const uint8_t *id) {
+ for (int32_t i = 0; ss && i < (int32_t)arrlen(ss->ring.keys); i++) {
+ if (memcmp(ss->ring.keys[i].id, id, PKG_KEY_ID_SIZE) == 0) {
+ return &ss->ring.keys[i];
+ }
+ }
+
+ return NULL;
+}
+
+
+// len bytes of text copied (malloc'd, with a NUL); NULL when out of
+// memory.
+static char *textCopy(const char *text, size_t len) {
+ char *copy = (char *)malloc(len + 1);
+
+ if (copy) {
+ memcpy(copy, text, len);
+ copy[len] = '\0';
+ }
+
+ return copy;
+}
diff --git a/src/libs/kpunch/pkg/pkgBas.h b/src/libs/kpunch/pkg/pkgBas.h
new file mode 100644
index 00000000..1b2eb354
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgBas.h
@@ -0,0 +1,106 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// pkgBas.h -- the C side of pkg.bas (pkgBas.c): the numbers BASIC
+// programs pass and get (pkg.bas's PKG_FIELD_* and PKG_RUN_*), which must
+// not change, and the functions BASIC calls.
+
+#ifndef PKG_BAS_H
+#define PKG_BAS_H
+
+#include
+
+// PKG_FIELD_*
+typedef enum {
+ PkgFieldIdE,
+ PkgFieldNameE,
+ PkgFieldVersionE,
+ PkgFieldKindE,
+ PkgFieldSummaryE,
+ PkgFieldPublisherE,
+ PkgFieldAuthorE,
+ PkgFieldLicenceE,
+ PkgFieldHomepageE,
+ PkgFieldDvxE,
+ PkgFieldAbiE,
+ PkgFieldSignerE, // the signing key's id (an offer's unchecked one when untrusted), or ""
+ PkgFieldSignerNameE, // its name when DVX trusts it, or ""
+ PkgFieldExplicitE, // installed: yes / no
+ PkgFieldTrustE, // offer: system / user / none
+ PkgFieldWhereE, // offer: its file
+ PkgFieldSizeE, // the bytes of its files
+ PkgFieldCountE
+} PkgFieldE;
+
+// PKG_RUN_*
+typedef enum {
+ PkgRunBusyE,
+ PkgRunDoneE,
+ PkgRunFailedE
+} PkgRunE;
+
+int32_t PkgBuild(int32_t s, const char *templatePath, const char *files, const char *outPath, const char *secretPath);
+void PkgClose(int32_t s);
+const char *PkgDependents(int32_t s, const char *ids);
+const char *PkgDependsRows(int32_t s, const char *id);
+int32_t PkgDvxAbi(void);
+const char *PkgDvxVersion(void);
+const char *PkgError(int32_t s);
+const char *PkgField(int32_t s, const char *id, int32_t field);
+const char *PkgFilesRows(int32_t s, const char *id);
+const char *PkgFolderRows(int32_t s, const char *folder, const char *prefix);
+const char *PkgIdPart(const char *text);
+int32_t PkgIdValid(const char *id);
+const char *PkgInstalledRows(int32_t s);
+int32_t PkgInstallStart(int32_t s);
+int32_t PkgKeygen(int32_t s, const char *keyName, const char *basePath);
+const char *PkgKeyInfo(int32_t s, const char *path);
+const char *PkgKindFolder(const char *kind, const char *id);
+const char *PkgKindName(int32_t kind);
+const char *PkgNeededBy(int32_t s, const char *id);
+int32_t PkgOfferCount(int32_t s);
+const char *PkgOfferDependsRows(int32_t s, int32_t offer);
+const char *PkgOfferField(int32_t s, int32_t offer, int32_t field);
+int32_t PkgOfferFile(int32_t s, const char *path);
+const char *PkgOfferFilesRows(int32_t s, int32_t offer);
+int32_t PkgOfferFolder(int32_t s, const char *dir);
+int32_t PkgOfferIndex(int32_t s, const char *id);
+int32_t PkgOpen(void);
+const char *PkgOrphans(int32_t s, const char *ids);
+int32_t PkgPlan(int32_t s, const char *offers);
+double PkgPlanBytes(int32_t s);
+int32_t PkgPlanRestart(int32_t s);
+const char *PkgPlanRows(int32_t s);
+const char *PkgPlanStale(int32_t s);
+int32_t PkgRemoveStart(int32_t s, const char *ids, int32_t removeChanged);
+void PkgRunCancel(int32_t s);
+const char *PkgRunCurrent(int32_t s);
+int32_t PkgRunFor(int32_t s, int32_t milliseconds);
+const char *PkgRunKept(int32_t s);
+const char *PkgRunMessage(int32_t s);
+int32_t PkgRunPercent(int32_t s);
+int32_t PkgSign(int32_t s, const char *dpkPath, const char *secretPath, int32_t detached);
+int32_t PkgTrustKey(int32_t s, const char *keyPath);
+int32_t PkgVersionValid(const char *text);
+
+#endif // PKG_BAS_H
diff --git a/src/libs/kpunch/pkg/pkgCli.c b/src/libs/kpunch/pkg/pkgCli.c
new file mode 100644
index 00000000..fc05ca50
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgCli.c
@@ -0,0 +1,637 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgCli.c -- the add-on package commands of the package tool (see
+// pkgCli.h)
+
+#include "pkgCli.h"
+#include "pkgDpk.h"
+#include "pkgOp.h"
+#include "pkgPlan.h"
+#include "dvxPlat.h"
+#include "monocypher.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+
+#define EXIT_OK 0
+#define EXIT_FAILED 1
+#define ARG_CMD 1
+#define ARG_FIRST 2
+#define ARGS_BUILD_MIN 5
+#define ARGS_BUILD_MAX 6
+#define ARGS_SIGN_MIN 4
+#define ARGS_SIGN_MAX 5
+#define ARGS_VERIFY_MIN 3
+#define ARGS_INFO 3
+#define ARGS_CHECK 3
+#define ARGS_KEYGEN 4
+#define ARGS_DVX 3
+#define OPT_DETACHED "-d"
+#define OPT_SIG "-s"
+#define OPT_ROOT "-r" // install / remove / list: the DVX folder
+#define OPT_YES "-y" // install: also what no trusted key signed
+#define OPT_FORCE "-f" // remove: also what needs it, and changed files
+#define LIST_ID_W 18 // list's columns
+#define LIST_VERSION_W 12
+#define LIST_KIND_W 10
+#define BYTES_PER_KB 1024
+
+// install / remove / list: -r ROOT, -y, -f, and the one other argument
+typedef struct {
+ const char *root;
+ const char *arg;
+ bool yes;
+ bool force;
+} CliOptsT;
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static int cmdBuild(int argc, char *argv[]);
+static int cmdCheck(const char *path);
+static int cmdDvx(const char *path);
+static int cmdInfo(const char *path);
+static int cmdInstall(const CliOptsT *o);
+static int cmdKeygen(const char *name, const char *base, PkgRandomFnT random);
+static int cmdList(const CliOptsT *o);
+static int cmdRemove(const CliOptsT *o);
+static int cmdSign(int argc, char *argv[]);
+static int cmdVerify(int argc, char *argv[]);
+static bool loadKey(const char *path, PkgKeyT *key);
+static bool loadSecret(const char *path, PkgSecretT *secret);
+static bool loadSig(const char *path, PkgSigT *sig);
+static bool parseOpts(int argc, char *argv[], CliOptsT *o);
+int pkgCliRun(int argc, char *argv[], PkgRandomFnT random, bool *handled);
+void pkgCliUsage(FILE *f);
+static int runOp(PkgOpT *op, const char *name);
+static bool said(const char *path, const char *err);
+
+
+static int cmdBuild(int argc, char *argv[]) {
+ PkgManifestT templ;
+ PkgSecretT secret;
+ char err[PKG_ERR_MAX];
+ bool keyed = argc == ARGS_BUILD_MAX;
+ bool ok;
+
+ if (!pkgTemplateRead(argv[ARG_FIRST], &templ, err, sizeof(err))) {
+ said(argv[ARG_FIRST], err);
+ return EXIT_FAILED;
+ }
+
+ if (keyed && !loadSecret(argv[ARGS_BUILD_MAX - 1], &secret)) {
+ pkgManifestFree(&templ);
+ return EXIT_FAILED;
+ }
+
+ ok = pkgDpkBuild(&templ, argv[ARG_FIRST + 1], argv[ARG_FIRST + 2], keyed ? &secret : NULL, err, sizeof(err));
+ pkgManifestFree(&templ);
+ pkgSecretWipe(&secret);
+
+ if (!ok) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ return EXIT_FAILED;
+ }
+
+ return cmdInfo(argv[ARG_FIRST + 2]);
+}
+
+
+// The package at path checked as DVX checks one before installing it (its
+// manifest, and every file's size and SHA-256 against it), and its
+// manifest written to stdout exactly as it is in the package: what the
+// package server reads from an upload.
+static int cmdCheck(const char *path) {
+ PkgDpkInfoT info;
+ char err[PKG_ERR_MAX];
+ bool ok;
+
+ if (!pkgDpkReadInfo(path, &info, err, sizeof(err))) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ return EXIT_FAILED;
+ }
+
+ ok = pkgDpkCheckFiles(path, &info, err, sizeof(err));
+
+ if (ok) {
+ fwrite(info.text, 1, (size_t)info.textLen, stdout);
+ } else {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ }
+
+ pkgDpkInfoFree(&info);
+ return ok ? EXIT_OK : EXIT_FAILED;
+}
+
+
+// DVX's own package template (this DVX's version and ABI) to path: what
+// the release's update of DVX is built from.
+static int cmdDvx(const char *path) {
+ PkgManifestT m;
+ int32_t len = 0;
+ char *text;
+ bool ok;
+
+ pkgDvxManifest(&m);
+ text = pkgManifestFormat(&m, &len);
+ ok = text && platformWriteFile(path, text, len);
+ free(text);
+ pkgManifestFree(&m);
+
+ if (!ok) {
+ fprintf(stderr, "dvxpkg: %s cannot be written\n", path);
+ return EXIT_FAILED;
+ }
+
+ return EXIT_OK;
+}
+
+
+static int cmdInfo(const char *path) {
+ PkgDpkInfoT info;
+ char err[PKG_ERR_MAX];
+ char version[PKG_VERSION_MAX];
+
+ if (!pkgDpkReadInfo(path, &info, err, sizeof(err))) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ return EXIT_FAILED;
+ }
+
+ const PkgManifestT *m = &info.manifest;
+ uint64_t bytes = 0;
+
+ for (int32_t i = 0; i < (int32_t)arrlen(m->files); i++) {
+ bytes += m->files[i].size;
+ }
+
+ pkgVersionFormat(m->version, version, sizeof(version));
+ printf("%s %s (%s): %s\n", m->id, version, pkgKindName(m->kind), m->name);
+
+ if (m->summary[0]) {
+ printf(" %s\n", m->summary);
+ }
+
+ if (m->publisher[0]) {
+ printf(" publisher: %s\n", m->publisher);
+ }
+
+ if (m->dvx.major || m->dvx.minor || m->dvx.patch) {
+ pkgVersionFormat(m->dvx, version, sizeof(version));
+ printf(" needs DVX %s or newer\n", version);
+ }
+
+ if (m->abi > 0) {
+ printf(" built for ABI %d\n", (int)m->abi);
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(m->depends); i++) {
+ pkgVersionFormat(m->depends[i].min, version, sizeof(version));
+ printf(" depends on %s %s or newer\n", m->depends[i].id, version);
+ }
+
+ printf(" %d file(s), %" PRIu64 " KB\n", (int)arrlen(m->files), (bytes + BYTES_PER_KB - 1) / BYTES_PER_KB);
+
+ if (info.hasSig) {
+ char id[PKG_KEY_ID_TEXT];
+
+ pkgKeyIdText(info.sig.keyId, id);
+ printf(" signed by key %s\n", id);
+ } else {
+ printf(" not signed\n");
+ }
+
+ pkgDpkInfoFree(&info);
+ return EXIT_OK;
+}
+
+
+// Install the package at o->arg (and what it needs from beside it) into
+// the DVX folder o->root. Anything no trusted key signed needs -y.
+static int cmdInstall(const CliOptsT *o) {
+ char err[PKG_ERR_MAX];
+ char userDir[DVX_MAX_PATH];
+ PkgDbT db;
+ PkgKeyringT ring;
+ PkgPlanT plan;
+ PkgOfferT *offers = NULL;
+ int32_t want = 0;
+ bool planned = false;
+ bool restart = false;
+ int rc = EXIT_FAILED;
+
+ memset(&plan, 0, sizeof(plan));
+
+ if (!pkgDbLoad(&db, o->root)) {
+ fprintf(stderr, "dvxpkg: out of memory\n");
+ return EXIT_FAILED;
+ }
+
+ pkgKeyringLoad(&ring, o->root, platformUserDirOf(userDir, sizeof(userDir), o->root[0] ? o->root : ".") ? userDir : NULL);
+ offers = pkgOffersFor(o->arg, &ring, err, sizeof(err));
+
+ if (!offers) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ } else if (!pkgPlanInstall(&db, offers, (int32_t)arrlen(offers), &want, 1, &plan)) {
+ fprintf(stderr, "dvxpkg: %s\n", plan.err);
+ } else {
+ planned = true;
+ }
+
+ // What will happen, and whether it may
+ bool untrusted = false;
+
+ for (int32_t i = 0; planned && i < (int32_t)arrlen(plan.steps); i++) {
+ static const char *const acts[] = { [PkgActInstallE] = "install", [PkgActUpgradeE] = "upgrade", [PkgActReinstallE] = "reinstall" };
+ const PkgOfferT *offer = &offers[plan.steps[i].offer];
+ char version[PKG_VERSION_MAX];
+
+ pkgVersionFormat(offer->manifest.version, version, sizeof(version));
+ printf(" %s %s %s%s\n", acts[plan.steps[i].act], offer->manifest.id, version, offer->trust == PkgTrustNoneE ? " (not signed by a key DVX trusts)" : "");
+ untrusted = untrusted || offer->trust == PkgTrustNoneE;
+ restart = restart || offer->manifest.kind == PkgKindLibraryE || offer->manifest.kind == PkgKindWidgetE || offer->manifest.kind == PkgKindSystemE;
+ }
+
+ if (planned && untrusted && !o->yes) {
+ fprintf(stderr, "dvxpkg: nothing installed: a key DVX trusts did not sign it all (" OPT_YES " installs it anyway)\n");
+ planned = false;
+ }
+
+ for (int32_t i = 0; planned && i < (int32_t)arrlen(plan.steps); i++) {
+ const PkgOfferT *offer = &offers[plan.steps[i].offer];
+
+ planned = runOp(pkgOpInstall(&db, offer->where, &ring, plan.steps[i].explicitly), offer->manifest.id) == EXIT_OK;
+ rc = planned ? EXIT_OK : EXIT_FAILED;
+ }
+
+ if (rc == EXIT_OK && restart) {
+ printf("Libraries and widgets work from the next time DVX starts.\n");
+ }
+
+ pkgPlanFree(&plan);
+ pkgOffersFree(offers);
+ pkgKeyringFree(&ring);
+ pkgDbFree(&db);
+ return rc;
+}
+
+
+static int cmdKeygen(const char *name, const char *base, PkgRandomFnT random) {
+ uint8_t seed[PKG_SEED_SIZE];
+ char err[PKG_ERR_MAX];
+ char id[PKG_KEY_ID_TEXT];
+ PkgKeyT key;
+ bool ok;
+
+ if (!random || !random(seed, PKG_SEED_SIZE)) {
+ fprintf(stderr, "dvxpkg: no source of random bytes for a key\n");
+ return EXIT_FAILED;
+ }
+
+ ok = pkgKeyWrite(seed, name, base, &key, err, sizeof(err));
+ crypto_wipe(seed, sizeof(seed));
+
+ if (!ok) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ return EXIT_FAILED;
+ }
+
+ pkgKeyIdText(key.id, id);
+ printf("secret key: %s%s (keep it private)\n", base, PKG_SECRET_EXT);
+ printf("public key: %s%s (id %s)\n", base, PKG_KEY_EXT, id);
+ return EXIT_OK;
+}
+
+
+// The packages installed in the DVX folder o->root.
+static int cmdList(const CliOptsT *o) {
+ PkgDbT db;
+
+ if (!pkgDbLoad(&db, o->root)) {
+ fprintf(stderr, "dvxpkg: out of memory\n");
+ return EXIT_FAILED;
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(db.pkgs); i++) {
+ const PkgManifestT *m = &db.pkgs[i].manifest;
+ char version[PKG_VERSION_MAX];
+
+ pkgVersionFormat(m->version, version, sizeof(version));
+ printf("%-*s %-*s %-*s %s\n", LIST_ID_W, m->id, LIST_VERSION_W, version, LIST_KIND_W, pkgKindName(m->kind), m->name);
+ }
+
+ pkgDbFree(&db);
+ return EXIT_OK;
+}
+
+
+// Remove the installed package o->arg from the DVX folder o->root. What
+// needs it, and files changed since they were installed, go only with -f.
+static int cmdRemove(const CliOptsT *o) {
+ PkgDbT db;
+ const PkgInstalledT **needers;
+ char **ids = NULL;
+ int rc = EXIT_OK;
+
+ if (!pkgDbLoad(&db, o->root)) {
+ fprintf(stderr, "dvxpkg: out of memory\n");
+ return EXIT_FAILED;
+ }
+
+ if (!pkgDbFind(&db, o->arg)) {
+ fprintf(stderr, "dvxpkg: %s is not installed\n", o->arg);
+ pkgDbFree(&db);
+ return EXIT_FAILED;
+ }
+
+ // Ids, not records: removing moves the records
+ needers = pkgPlanDependents(&db, &o->arg, 1);
+
+ for (int32_t i = 0; i < (int32_t)arrlen(needers); i++) {
+ arrput(ids, strdup(needers[i]->manifest.id));
+ }
+
+ arrput(ids, strdup(o->arg));
+
+ if (arrlen(needers) > 0 && !o->force) {
+ fprintf(stderr, "dvxpkg: nothing removed: needed by");
+
+ for (int32_t i = 0; i < (int32_t)arrlen(needers); i++) {
+ fprintf(stderr, " %s", needers[i]->manifest.id);
+ }
+
+ fprintf(stderr, " (" OPT_FORCE " removes them too)\n");
+ rc = EXIT_FAILED;
+ }
+
+ for (int32_t i = 0; rc == EXIT_OK && i < (int32_t)arrlen(ids); i++) {
+ printf(" remove %s\n", ids[i]);
+ rc = runOp(pkgOpRemove(&db, ids[i], o->force), ids[i]);
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(ids); i++) {
+ free(ids[i]);
+ }
+
+ arrfree(ids);
+ arrfree(needers);
+ pkgDbFree(&db);
+ return rc;
+}
+
+
+static int cmdSign(int argc, char *argv[]) {
+ PkgSecretT secret;
+ char err[PKG_ERR_MAX];
+ bool detached = argc == ARGS_SIGN_MAX && strcmp(argv[ARGS_SIGN_MAX - 1], OPT_DETACHED) == 0;
+
+ if (argc == ARGS_SIGN_MAX && !detached) {
+ pkgCliUsage(stderr);
+ return PKG_CLI_EXIT_USAGE;
+ }
+
+ if (!loadSecret(argv[ARG_FIRST + 1], &secret)) {
+ return EXIT_FAILED;
+ }
+
+ bool ok = pkgDpkSign(argv[ARG_FIRST], &secret, detached, err, sizeof(err));
+
+ pkgSecretWipe(&secret);
+
+ if (!ok) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ return EXIT_FAILED;
+ }
+
+ printf("signed %s%s\n", argv[ARG_FIRST], detached ? " (beside it)" : "");
+ return EXIT_OK;
+}
+
+
+static int cmdVerify(int argc, char *argv[]) {
+ PkgDpkInfoT info;
+ PkgSigT detached;
+ PkgKeyT *keys = NULL;
+ bool hasSig = false;
+ char err[PKG_ERR_MAX];
+ int result = EXIT_FAILED;
+
+ for (int32_t i = ARG_FIRST + 1; i < argc; i++) {
+ if (strcmp(argv[i], OPT_SIG) == 0 && i + 1 < argc) {
+ if (!loadSig(argv[++i], &detached)) {
+ arrfree(keys);
+ return EXIT_FAILED;
+ }
+
+ hasSig = true;
+ continue;
+ }
+
+ PkgKeyT key;
+
+ if (!loadKey(argv[i], &key)) {
+ arrfree(keys);
+ return EXIT_FAILED;
+ }
+
+ arrput(keys, key);
+ }
+
+ if (!pkgDpkReadInfo(argv[ARG_FIRST], &info, err, sizeof(err))) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ arrfree(keys);
+ return EXIT_FAILED;
+ }
+
+ if (!pkgDpkCheckFiles(argv[ARG_FIRST], &info, err, sizeof(err))) {
+ fprintf(stderr, "dvxpkg: %s\n", err);
+ } else {
+ const PkgKeyT *signer = pkgDpkSigner(&info, hasSig ? &detached : NULL, keys, (int32_t)arrlen(keys));
+
+ printf("%s: every file matches its manifest\n", argv[ARG_FIRST]);
+ fflush(stdout);
+
+ if (signer) {
+ char id[PKG_KEY_ID_TEXT];
+
+ pkgKeyIdText(signer->id, id);
+ printf("signed by %s (key %s)\n", signer->name, id);
+ result = EXIT_OK;
+ } else if (arrlen(keys) > 0) {
+ fprintf(stderr, "dvxpkg: not signed by any of the keys given\n");
+ } else {
+ printf("signature not checked (no key given)\n");
+ result = EXIT_OK;
+ }
+ }
+
+ pkgDpkInfoFree(&info);
+ arrfree(keys);
+ return result;
+}
+
+
+static bool loadKey(const char *path, PkgKeyT *key) {
+ char err[PKG_ERR_MAX];
+
+ return pkgKeyRead(path, key, err, sizeof(err)) || said(path, err);
+}
+
+
+static bool loadSecret(const char *path, PkgSecretT *secret) {
+ char err[PKG_ERR_MAX];
+
+ return pkgSecretRead(path, secret, err, sizeof(err)) || said(path, err);
+}
+
+
+static bool loadSig(const char *path, PkgSigT *sig) {
+ char err[PKG_ERR_MAX];
+
+ return pkgSigRead(path, sig, err, sizeof(err)) || said(path, err);
+}
+
+
+// install / remove / list's options; false for anything else (or a second
+// argument).
+static bool parseOpts(int argc, char *argv[], CliOptsT *o) {
+ memset(o, 0, sizeof(*o));
+ o->root = "";
+
+ for (int32_t i = ARG_FIRST; i < argc; i++) {
+ if (strcmp(argv[i], OPT_ROOT) == 0 && i + 1 < argc) {
+ o->root = argv[++i];
+ } else if (strcmp(argv[i], OPT_YES) == 0) {
+ o->yes = true;
+ } else if (strcmp(argv[i], OPT_FORCE) == 0) {
+ o->force = true;
+ } else if (!o->arg && argv[i][0] != '-') {
+ o->arg = argv[i];
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+int pkgCliRun(int argc, char *argv[], PkgRandomFnT random, bool *handled) {
+ const char *cmd = argc > ARG_CMD ? argv[ARG_CMD] : "";
+
+ *handled = true;
+
+ if (strcmp(cmd, "build") == 0 && argc >= ARGS_BUILD_MIN && argc <= ARGS_BUILD_MAX) {
+ return cmdBuild(argc, argv);
+ }
+
+ if (strcmp(cmd, "sign") == 0 && argc >= ARGS_SIGN_MIN && argc <= ARGS_SIGN_MAX) {
+ return cmdSign(argc, argv);
+ }
+
+ if (strcmp(cmd, "verify") == 0 && argc >= ARGS_VERIFY_MIN) {
+ return cmdVerify(argc, argv);
+ }
+
+ if (strcmp(cmd, "check") == 0 && argc == ARGS_CHECK) {
+ return cmdCheck(argv[ARG_FIRST]);
+ }
+
+ if (strcmp(cmd, "dvx") == 0 && argc == ARGS_DVX) {
+ return cmdDvx(argv[ARG_FIRST]);
+ }
+
+ if (strcmp(cmd, "info") == 0 && argc == ARGS_INFO) {
+ return cmdInfo(argv[ARG_FIRST]);
+ }
+
+ if (strcmp(cmd, "keygen") == 0 && argc == ARGS_KEYGEN) {
+ return cmdKeygen(argv[ARG_FIRST], argv[ARG_FIRST + 1], random);
+ }
+
+ CliOptsT o;
+
+ if (strcmp(cmd, "install") == 0 && parseOpts(argc, argv, &o) && o.arg) {
+ return cmdInstall(&o);
+ }
+
+ if (strcmp(cmd, "remove") == 0 && parseOpts(argc, argv, &o) && o.arg) {
+ return cmdRemove(&o);
+ }
+
+ if (strcmp(cmd, "list") == 0 && parseOpts(argc, argv, &o) && !o.arg) {
+ return cmdList(&o);
+ }
+
+ *handled = false;
+ return PKG_CLI_EXIT_USAGE;
+}
+
+
+void pkgCliUsage(FILE *f) {
+ fprintf(f, " dvxpkg build TEMPLATE.INI FOLDER OUT.DPK [KEY.SEC]\n");
+ fprintf(f, " dvxpkg sign NAME.DPK KEY.SEC [-d]\n");
+ fprintf(f, " dvxpkg verify NAME.DPK [-s NAME.SIG] [KEY.KEY ...]\n");
+ fprintf(f, " dvxpkg info NAME.DPK\n");
+ fprintf(f, " dvxpkg check NAME.DPK (the manifest, when every file matches it)\n");
+ fprintf(f, " dvxpkg keygen \"Key name\" BASE\n");
+ fprintf(f, " dvxpkg dvx TEMPLATE.INI (DVX's own package template)\n");
+ fprintf(f, " dvxpkg install NAME.DPK [-r DVXDIR] [-y]\n");
+ fprintf(f, " dvxpkg remove VENDOR.NAME [-r DVXDIR] [-f]\n");
+ fprintf(f, " dvxpkg list [-r DVXDIR]\n");
+}
+
+
+// Run an install or remove to its end, saying the files it kept; name is
+// the package's, for a failure.
+static int runOp(PkgOpT *op, const char *name) {
+ PkgOpStateE st;
+
+ if (!op) {
+ fprintf(stderr, "dvxpkg: out of memory\n");
+ return EXIT_FAILED;
+ }
+
+ while ((st = pkgOpStep(op)) == PkgOpRunningE) {
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(pkgOpKept(op)); i++) {
+ printf(" kept %s (changed since it was installed)\n", pkgOpKept(op)[i]);
+ }
+
+ if (st == PkgOpFailedE) {
+ fprintf(stderr, "dvxpkg: %s: %s\n", name, pkgOpMessage(op));
+ }
+
+ pkgOpFree(op);
+ return st == PkgOpDoneE ? EXIT_OK : EXIT_FAILED;
+}
+
+
+// Say why a file could not be used; false.
+static bool said(const char *path, const char *err) {
+ fprintf(stderr, "dvxpkg: %s: %s\n", path, err);
+ return false;
+}
diff --git a/src/libs/kpunch/pkg/pkgCli.h b/src/libs/kpunch/pkg/pkgCli.h
new file mode 100644
index 00000000..ab3e37b0
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgCli.h
@@ -0,0 +1,58 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgCli.h -- the add-on package commands of the package tool: dvxpkg on
+// the build machine (with Setup's own commands, src/installer/pkgTool.c)
+// and DVXPKG.EXE in DVX
+//
+// build TEMPLATE.INI FOLDER OUT.DPK [KEY.SEC] make a package
+// sign NAME.DPK KEY.SEC [-d] sign it (-d: NAME.SIG beside it)
+// verify NAME.DPK [-s NAME.SIG] [KEY.KEY ...] check every file and the signature
+// info NAME.DPK what it is, needs, and holds
+// keygen "Key name" BASE BASE.KEY (public) and BASE.SEC (secret)
+// install NAME.DPK [-r DVXDIR] [-y] install it (and what it needs from beside
+// it) into DVX; -y: also what no trusted key signed
+// remove VENDOR.NAME [-r DVXDIR] [-f] remove it; -f: also what needs it, and
+// files changed since they were installed
+// list [-r DVXDIR] the packages installed
+
+#ifndef PKG_CLI_H
+#define PKG_CLI_H
+
+#include
+#include
+#include
+
+#define PKG_CLI_EXIT_USAGE 2
+
+// Fill buf with len random bytes from the best source the platform has;
+// false when there is none.
+typedef bool (*PkgRandomFnT)(uint8_t *buf, int32_t len);
+
+// Run argv's command (argv[1]) if it is one of the above: its exit code,
+// *handled true. Otherwise *handled is false and nothing was done.
+int pkgCliRun(int argc, char *argv[], PkgRandomFnT random, bool *handled);
+
+// The commands' usage lines.
+void pkgCliUsage(FILE *f);
+
+#endif // PKG_CLI_H
diff --git a/src/libs/kpunch/pkg/pkgDb.c b/src/libs/kpunch/pkg/pkgDb.c
new file mode 100644
index 00000000..c74f7d39
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgDb.c
@@ -0,0 +1,376 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// pkgDb.c -- the records of the packages installed in a DVX folder
+// (pkgDb.h)
+
+#include "pkgDb.h"
+#include "pkgInt.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define SECTION_INSTALLED "installed"
+#define KEY_EXPLICIT "explicit"
+#define KEY_SIGNER "signer"
+#define YES_TEXT "yes"
+#define NO_TEXT "no"
+#define INSTALLED_MAX 128 // the [installed] section's text
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static int compareIds(const void *a, const void *b);
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) __attribute__((format(printf, 3, 4)));
+static void installedFree(PkgInstalledT *pkg);
+static bool loadRecord(PkgDbT *db, const char *path, const char *vendor, const char *file);
+bool pkgDbDrop(PkgDbT *db, const char *id, char *err, int32_t errSize);
+const PkgInstalledT *pkgDbFind(const PkgDbT *db, const char *id);
+char *pkgDbFormat(const PkgManifestT *m, bool explicitly, const uint8_t *signer, int32_t *outLen);
+void pkgDbFree(PkgDbT *db);
+bool pkgDbLoad(PkgDbT *db, const char *root);
+const PkgInstalledT *pkgDbOwner(const PkgDbT *db, const char *name, const char *exceptId);
+bool pkgDbParse(const char *text, int32_t len, PkgInstalledT *pkg, char *err, int32_t errSize);
+bool pkgDbPut(PkgDbT *db, const PkgManifestT *m, bool explicitly, const uint8_t *signer, char *err, int32_t errSize);
+bool pkgDbRecordPath(const char *root, const char *id, char *out, int32_t outSize);
+
+
+static int compareIds(const void *a, const void *b) {
+ return strcasecmp(((const PkgInstalledT *)a)->manifest.id, ((const PkgInstalledT *)b)->manifest.id);
+}
+
+
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) {
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(err, (size_t)errSize, fmt, ap);
+ va_end(ap);
+ return false;
+}
+
+
+static void installedFree(PkgInstalledT *pkg) {
+ pkgManifestFree(&pkg->manifest);
+}
+
+
+// One record file (VENDOR\FILE under the records' folder): kept when it
+// reads and is where its id says. False only when out of memory.
+static bool loadRecord(PkgDbT *db, const char *path, const char *vendor, const char *file) {
+ char err[PKG_ERR_MAX];
+ char want[DVX_MAX_PATH];
+ PkgInstalledT pkg;
+ int32_t len = 0;
+ char *text = platformReadFile(path, &len);
+
+ if (!text) {
+ dvxLog("Packages: cannot read %s", path);
+ return true;
+ }
+
+ if (!pkgDbParse(text, len, &pkg, err, sizeof(err))) {
+ dvxLog("Packages: %s skipped: %s", path, err);
+ free(text);
+ return true;
+ }
+
+ free(text);
+
+ // VENDOR\NAME.INI must be the record of vendor.name
+ char idVendor[PKG_ID_PART_MAX + 1];
+ char idName[PKG_ID_PART_MAX + 1];
+
+ pkgIdSplit(pkg.manifest.id, idVendor, idName);
+ snprintf(want, sizeof(want), "%s" PKG_DB_EXT, idName);
+
+ if (strcasecmp(vendor, idVendor) != 0 || strcasecmp(file, want) != 0) {
+ dvxLog("Packages: %s skipped: it is the record of %s", path, pkg.manifest.id);
+ installedFree(&pkg);
+ return true;
+ }
+
+ // Two spellings of one name (a case-sensitive disk): the first is kept
+ if (pkgDbFind(db, pkg.manifest.id)) {
+ dvxLog("Packages: %s skipped: %s has a record already", path, pkg.manifest.id);
+ installedFree(&pkg);
+ return true;
+ }
+
+ arrput(db->pkgs, pkg);
+ return db->pkgs != NULL;
+}
+
+
+bool pkgDbDrop(PkgDbT *db, const char *id, char *err, int32_t errSize) {
+ char path[DVX_MAX_PATH];
+
+ if (!pkgDbRecordPath(db->root, id, path, sizeof(path))) {
+ return fail(err, errSize, "%s is not a package id.", id);
+ }
+
+ if (remove(path) != 0) {
+ struct stat st;
+
+ if (stat(path, &st) == 0) {
+ return fail(err, errSize, "Cannot remove %s.", path);
+ }
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(db->pkgs); i++) {
+ if (strcasecmp(db->pkgs[i].manifest.id, id) == 0) {
+ installedFree(&db->pkgs[i]);
+ arrdel(db->pkgs, i);
+ break;
+ }
+ }
+
+ // The vendor's folder goes with its last record
+ char dir[DVX_MAX_PATH];
+
+ if (platformPathDirName(path, dir, sizeof(dir))) {
+ rmdir(dir);
+ }
+
+ return true;
+}
+
+
+const PkgInstalledT *pkgDbFind(const PkgDbT *db, const char *id) {
+ for (int32_t i = 0; i < (int32_t)arrlen(db->pkgs); i++) {
+ if (strcasecmp(db->pkgs[i].manifest.id, id) == 0) {
+ return &db->pkgs[i];
+ }
+ }
+
+ return NULL;
+}
+
+
+char *pkgDbFormat(const PkgManifestT *m, bool explicitly, const uint8_t *signer, int32_t *outLen) {
+ char installed[INSTALLED_MAX];
+ char signerHex[PKG_KEY_ID_TEXT];
+ int32_t len = 0;
+ char *text = pkgManifestFormat(m, &len);
+ int32_t n;
+
+ if (!text) {
+ return NULL;
+ }
+
+ signerHex[0] = '\0';
+
+ if (signer) {
+ pkgHexEncode(signer, PKG_KEY_ID_SIZE, signerHex);
+ }
+
+ n = snprintf(installed, sizeof(installed), "\r\n[" SECTION_INSTALLED "]\r\n" KEY_EXPLICIT " = %s\r\n" KEY_SIGNER " = %s\r\n", explicitly ? YES_TEXT : NO_TEXT, signerHex);
+
+ char *all = (char *)realloc(text, (size_t)len + (size_t)n + 1);
+
+ if (!all) {
+ free(text);
+ return NULL;
+ }
+
+ memcpy(all + len, installed, (size_t)n + 1);
+ *outLen = len + n;
+ return all;
+}
+
+
+void pkgDbFree(PkgDbT *db) {
+ for (int32_t i = 0; i < (int32_t)arrlen(db->pkgs); i++) {
+ installedFree(&db->pkgs[i]);
+ }
+
+ arrfree(db->pkgs);
+ db->pkgs = NULL;
+}
+
+
+bool pkgDbLoad(PkgDbT *db, const char *root) {
+ char sys[DVX_MAX_PATH];
+ char dir[DVX_MAX_PATH];
+ char **vendors;
+ bool ok = true;
+
+ memset(db, 0, sizeof(*db));
+ snprintf(db->root, sizeof(db->root), "%s", root);
+
+ if (!platformPathJoin(sys, sizeof(sys), root, DVX_SYSTEM_DIR) || !platformPathJoin(dir, sizeof(dir), sys, PKG_DB_DIR)) {
+ return true;
+ }
+
+ vendors = dvxReadDir(dir);
+
+ for (int32_t v = 0; ok && v < (int32_t)arrlen(vendors); v++) {
+ char vendorDir[DVX_MAX_PATH];
+ char **files;
+
+ if (!platformPathJoin(vendorDir, sizeof(vendorDir), dir, vendors[v])) {
+ continue;
+ }
+
+ files = dvxReadDir(vendorDir);
+
+ for (int32_t f = 0; ok && f < (int32_t)arrlen(files); f++) {
+ char path[DVX_MAX_PATH];
+
+ if (dvxHasExt(files[f], PKG_DB_EXT) && platformPathJoin(path, sizeof(path), vendorDir, files[f])) {
+ ok = loadRecord(db, path, vendors[v], files[f]);
+ }
+ }
+
+ dvxReadDirFree(files);
+ }
+
+ dvxReadDirFree(vendors);
+
+ if (db->pkgs) {
+ qsort(db->pkgs, (size_t)arrlen(db->pkgs), sizeof(db->pkgs[0]), compareIds);
+ }
+
+ return ok;
+}
+
+
+const PkgInstalledT *pkgDbOwner(const PkgDbT *db, const char *name, const char *exceptId) {
+ for (int32_t i = 0; i < (int32_t)arrlen(db->pkgs); i++) {
+ const PkgManifestT *m = &db->pkgs[i].manifest;
+
+ if (exceptId && strcasecmp(m->id, exceptId) == 0) {
+ continue;
+ }
+
+ for (int32_t f = 0; f < (int32_t)arrlen(m->files); f++) {
+ if (platformPathSame(m->files[f].name, name)) {
+ return &db->pkgs[i];
+ }
+ }
+ }
+
+ return NULL;
+}
+
+
+bool pkgDbParse(const char *text, int32_t len, PkgInstalledT *pkg, char *err, int32_t errSize) {
+ PkgIniT ini;
+ PkgIniStepE step;
+
+ memset(pkg, 0, sizeof(*pkg));
+
+ if (!pkgManifestParse(text, len, &pkg->manifest, err, errSize)) {
+ return false;
+ }
+
+ if (!pkgManifestCheck(&pkg->manifest, err, errSize)) {
+ installedFree(pkg);
+ return false;
+ }
+
+ // The manifest reader passed [installed] by
+ pkgIniStart(&ini, text, len);
+
+ while ((step = pkgIniNext(&ini)) != PkgIniEndE) {
+ if (step != PkgIniKeyE || strcmp(ini.section, SECTION_INSTALLED) != 0) {
+ continue;
+ }
+
+ if (strcasecmp(ini.key, KEY_EXPLICIT) == 0) {
+ pkg->explicitly = strcasecmp(ini.value, YES_TEXT) == 0;
+ } else if (strcasecmp(ini.key, KEY_SIGNER) == 0) {
+ pkg->hasSigner = pkgHexDecode(ini.value, pkg->signer, PKG_KEY_ID_SIZE);
+ }
+ }
+
+ return true;
+}
+
+
+bool pkgDbPut(PkgDbT *db, const PkgManifestT *m, bool explicitly, const uint8_t *signer, char *err, int32_t errSize) {
+ char path[DVX_MAX_PATH];
+ char dir[DVX_MAX_PATH];
+ PkgInstalledT pkg;
+ int32_t len = 0;
+ char *text = NULL;
+ bool ok;
+
+ if (!pkgDbRecordPath(db->root, m->id, path, sizeof(path)) || !platformPathDirName(path, dir, sizeof(dir))) {
+ return fail(err, errSize, "%s is not a package id.", m->id);
+ }
+
+ text = pkgDbFormat(m, explicitly, signer, &len);
+
+ if (!text) {
+ return fail(err, errSize, "There is not enough memory.");
+ }
+
+ // Kept as read back, so the entry is the record exactly
+ ok = pkgDbParse(text, len, &pkg, err, errSize);
+
+ if (ok && (platformMkdirRecursive(dir) != 0 || !platformWriteFile(path, text, len))) {
+ installedFree(&pkg);
+ ok = fail(err, errSize, "Cannot write %s.", path);
+ }
+
+ free(text);
+
+ if (!ok) {
+ return false;
+ }
+
+ for (int32_t i = 0; i < (int32_t)arrlen(db->pkgs); i++) {
+ if (strcasecmp(db->pkgs[i].manifest.id, m->id) == 0) {
+ installedFree(&db->pkgs[i]);
+ db->pkgs[i] = pkg;
+ return true;
+ }
+ }
+
+ arrput(db->pkgs, pkg);
+ qsort(db->pkgs, (size_t)arrlen(db->pkgs), sizeof(db->pkgs[0]), compareIds);
+ return true;
+}
+
+
+bool pkgDbRecordPath(const char *root, const char *id, char *out, int32_t outSize) {
+ char vendor[PKG_ID_PART_MAX + 1];
+ char name[PKG_ID_PART_MAX + 1];
+ char path[DVX_MAX_PATH];
+
+ if (!pkgIdSplit(id, vendor, name)) {
+ return false;
+ }
+
+ int32_t n = snprintf(path, sizeof(path), DVX_SYSTEM_DIR DVX_PATH_SEP PKG_DB_DIR DVX_PATH_SEP "%s" DVX_PATH_SEP "%s" PKG_DB_EXT, vendor, name);
+
+ return n > 0 && n < (int32_t)sizeof(path) && platformPathJoin(out, outSize, root, path);
+}
diff --git a/src/libs/kpunch/pkg/pkgDb.h b/src/libs/kpunch/pkg/pkgDb.h
new file mode 100644
index 00000000..67566c70
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgDb.h
@@ -0,0 +1,96 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// pkgDb.h -- the packages installed in a DVX folder
+//
+// Each installed package has a record, SYSTEM\PACKAGES\VENDOR\NAME.INI:
+// its manifest as installed (every file it put there, with its size and
+// SHA-256, so a file changed since can be told) and how it came:
+//
+// [installed]
+// explicit = yes ; chosen (no: needed by one that was)
+// signer = 0123456789abcdef ; the key that signed it (none: unsigned)
+//
+// DVX itself is kpunch.dvx; Setup writes its record, with no [files]
+// (Setup keeps DVX's own list). Records are read once (pkgDbLoad) and
+// kept in step as packages are installed and removed.
+
+#ifndef PKG_DB_H
+#define PKG_DB_H
+
+#include "pkgManifest.h"
+#include "pkgSign.h"
+#include "dvxPlat.h"
+
+#include
+#include
+
+#define PKG_DB_DIR "PACKAGES" // in SYSTEM
+#define PKG_DB_EXT ".INI"
+
+typedef struct {
+ PkgManifestT manifest;
+ bool explicitly; // chosen, not only needed by another
+ bool hasSigner;
+ uint8_t signer[PKG_KEY_ID_SIZE];
+} PkgInstalledT;
+
+typedef struct {
+ char root[DVX_MAX_PATH]; // the DVX folder ("": the current one)
+ PkgInstalledT *pkgs; // stb_ds array, by id
+} PkgDbT;
+
+// Forget id's record (its file and the entry). False with the reason.
+bool pkgDbDrop(PkgDbT *db, const char *id, char *err, int32_t errSize);
+
+// The installed package id (any case), or NULL.
+const PkgInstalledT *pkgDbFind(const PkgDbT *db, const char *id);
+
+// A record's text (malloc'd, CR LF lines; *outLen its length): m with
+// its [installed] section. NULL when out of memory.
+char *pkgDbFormat(const PkgManifestT *m, bool explicitly, const uint8_t *signer, int32_t *outLen);
+
+void pkgDbFree(PkgDbT *db);
+
+// Read every record of the DVX folder root ("": the current one). A
+// record that cannot be read, or is not where its id says, is skipped and
+// logged. False only when out of memory.
+bool pkgDbLoad(PkgDbT *db, const char *root);
+
+// The installed package other than exceptId (NULL: any) that has the
+// file name (relative, either separator, any case), or NULL.
+const PkgInstalledT *pkgDbOwner(const PkgDbT *db, const char *name, const char *exceptId);
+
+// Read a record's text and check its manifest. False with the reason
+// (nothing to free).
+bool pkgDbParse(const char *text, int32_t len, PkgInstalledT *pkg, char *err, int32_t errSize);
+
+// Write m's record (replacing one there) and keep it. signer is the key
+// id that signed it, or NULL. False with the reason.
+bool pkgDbPut(PkgDbT *db, const PkgManifestT *m, bool explicitly, const uint8_t *signer, char *err, int32_t errSize);
+
+// Where id's record is under root. False when the path does not fit or
+// id is not an id.
+bool pkgDbRecordPath(const char *root, const char *id, char *out, int32_t outSize);
+
+#endif // PKG_DB_H
diff --git a/src/libs/kpunch/pkg/pkgDosMain.c b/src/libs/kpunch/pkg/pkgDosMain.c
new file mode 100644
index 00000000..4a956294
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgDosMain.c
@@ -0,0 +1,54 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+
+// pkgDosMain.c -- DVXPKG.EXE, the package tool on DOS (SYSTEM\DVXPKG.EXE):
+// the commands of pkgCli.h, run from the DVX folder (or with -r DVXDIR),
+// for example to remove an add-on that keeps DVX from starting. Key
+// pairs are made from pkgRandomBytes.
+
+#include "pkgCli.h"
+#include "pkgSign.h"
+
+#include
+#include
+#include
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+int main(int argc, char *argv[]);
+
+
+int main(int argc, char *argv[]) {
+ bool handled = false;
+ int rc = pkgCliRun(argc, argv, pkgRandomBytes, &handled);
+
+ if (!handled) {
+ fprintf(stderr, "Usage:\n");
+ pkgCliUsage(stderr);
+ return PKG_CLI_EXIT_USAGE;
+ }
+
+ return rc;
+}
diff --git a/src/libs/kpunch/pkg/pkgDpk.c b/src/libs/kpunch/pkg/pkgDpk.c
new file mode 100644
index 00000000..0459b591
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgDpk.c
@@ -0,0 +1,642 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgDpk.c -- add-on package files (see pkgDpk.h)
+
+#include "pkgDpk.h"
+#include "pkgZip.h"
+#include "pkgInt.h"
+#include "dvxPlat.h"
+#include "dvxTypes.h"
+#include "serial/security/secHash.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define READ_CHUNK 16384 // bytes hashed at a time
+#define ENTRY_STEP 32768 // work per pkgZipData call (no one waits here)
+#define SIG_TEXT_MAX 1024 // largest PACKAGE.SIG read
+#define TEMP_EXT ".$PK" // the package being rewritten by pkgDpkSign
+#define PATH_SEP_TEXT "/"
+#define DOT '.'
+
+// A ZIP entry's data into memory
+typedef struct {
+ uint8_t *buf;
+ uint32_t len;
+ uint32_t cap;
+} MemSinkT;
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static int compareFiles(const void *a, const void *b);
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) __attribute__((format(printf, 3, 4)));
+static int32_t fileRead(void *ctx, uint8_t *buf, int32_t len);
+static bool hashFile(const char *path, uint32_t *size, uint8_t sha[PKG_SHA256_SIZE]);
+static bool hashWrite(void *ctx, const uint8_t *data, int32_t len);
+static bool joinName(char *out, int32_t outSize, const char *dir, const char *name);
+static bool memWrite(void *ctx, const uint8_t *data, int32_t len);
+bool pkgDpkBuild(const PkgManifestT *templ, const char *folder, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize);
+bool pkgDpkBuildFiles(const PkgManifestT *templ, const PkgDpkFileT *files, int32_t count, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize);
+bool pkgDpkCheckFiles(const char *path, const PkgDpkInfoT *info, char *err, int32_t errSize);
+void pkgDpkFilesFree(PkgDpkFileT *files);
+bool pkgDpkFolderFiles(const char *folder, const char *prefix, PkgDpkFileT **files);
+void pkgDpkInfoFree(PkgDpkInfoT *info);
+bool pkgDpkReadInfo(const char *path, PkgDpkInfoT *info, char *err, int32_t errSize);
+bool pkgDpkSign(const char *path, const PkgSecretT *secret, bool detached, char *err, int32_t errSize);
+const PkgKeyT *pkgDpkSigner(const PkgDpkInfoT *info, const PkgSigT *detached, const PkgKeyT *keys, int32_t count);
+static bool readEntry(PkgZipT *zip, const PkgZipEntryT *e, uint32_t max, MemSinkT *sink);
+static bool sameFile(const char *a, const char *b);
+static bool siblingPath(const char *path, const char *ext, char *out, int32_t outSize);
+static void sortFiles(PkgDpkFileT *files);
+static bool walk(const char *folder, const char *rel, const char *prefix, PkgDpkFileT **files);
+static const char *zipReason(const PkgZipT *zip);
+
+
+// Two files to build by name.
+static int compareFiles(const void *a, const void *b) {
+ return strcasecmp(((const PkgDpkFileT *)a)->name, ((const PkgDpkFileT *)b)->name);
+}
+
+
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) {
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(err, (size_t)errSize, fmt, ap);
+ va_end(ap);
+ return false;
+}
+
+
+static int32_t fileRead(void *ctx, uint8_t *buf, int32_t len) {
+ FILE *f = (FILE *)ctx;
+ size_t n = fread(buf, 1, (size_t)len, f);
+
+ return (n == 0 && ferror(f)) ? -1 : (int32_t)n;
+}
+
+
+static bool hashFile(const char *path, uint32_t *size, uint8_t sha[PKG_SHA256_SIZE]) {
+ uint8_t buf[READ_CHUNK];
+ SecSha256T ctx;
+ uint64_t total = 0;
+ size_t n;
+ FILE *f = fopen(path, "rb");
+
+ if (!f) {
+ return false;
+ }
+
+ secSha256Init(&ctx);
+
+ while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
+ secSha256Update(&ctx, buf, (int32_t)n);
+ total += n;
+ }
+
+ bool ok = !ferror(f) && total <= UINT32_MAX;
+
+ fclose(f);
+ secSha256Final(&ctx, sha);
+ *size = (uint32_t)total;
+ return ok;
+}
+
+
+static bool hashWrite(void *ctx, const uint8_t *data, int32_t len) {
+ secSha256Update((SecSha256T *)ctx, data, len);
+ return true;
+}
+
+
+// dir/name ('/' between them; name alone when dir is "").
+static bool joinName(char *out, int32_t outSize, const char *dir, const char *name) {
+ int32_t n = dir[0] ? snprintf(out, (size_t)outSize, "%s" PATH_SEP_TEXT "%s", dir, name) : snprintf(out, (size_t)outSize, "%s", name);
+
+ return n >= 0 && n < outSize;
+}
+
+
+static bool memWrite(void *ctx, const uint8_t *data, int32_t len) {
+ MemSinkT *sink = (MemSinkT *)ctx;
+
+ if (sink->len + (uint32_t)len > sink->cap) {
+ return false;
+ }
+
+ memcpy(sink->buf + sink->len, data, (size_t)len);
+ sink->len += (uint32_t)len;
+ return true;
+}
+
+
+// Every file under folder, named relative to it.
+bool pkgDpkBuild(const PkgManifestT *templ, const char *folder, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize) {
+ PkgDpkFileT *files = NULL;
+ bool ok = pkgDpkFolderFiles(folder, "", &files) || fail(err, errSize, "Cannot read the folder %s.", folder);
+
+ ok = ok && pkgDpkBuildFiles(templ, files, (int32_t)arrlen(files), outPath, secret, err, errSize);
+ pkgDpkFilesFree(files);
+ return ok;
+}
+
+
+bool pkgDpkBuildFiles(const PkgManifestT *templ, const PkgDpkFileT *files, int32_t count, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize) {
+ PkgManifestT m;
+ PkgDpkFileT *sorted = NULL; // the files' entries (not their text) copied
+ char *text = NULL;
+ int32_t len = 0;
+ bool ok = true;
+
+ // The template's fields and dependencies (code made for this DVX when
+ // it does not say); the files are these
+ m = *templ;
+ m.format = PKG_MANIFEST_FORMAT;
+ m.abi = (pkgKindIsCode(m.kind) && m.abi <= 0) ? DVX_ABI_VERSION : m.abi;
+ m.files = NULL;
+ m.depends = NULL;
+
+ for (int32_t i = 0; i < (int32_t)arrlen(templ->depends); i++) {
+ arrput(m.depends, templ->depends[i]);
+ }
+
+ // In name order (the package being written is never one of them)
+ for (int32_t i = 0; i < count; i++) {
+ if (!sameFile(files[i].source, outPath)) {
+ arrput(sorted, files[i]);
+ }
+ }
+
+ sortFiles(sorted);
+
+ for (int32_t i = 0; ok && i < (int32_t)arrlen(sorted); i++) {
+ uint8_t sha[PKG_SHA256_SIZE];
+ uint32_t size = 0;
+
+ if (!hashFile(sorted[i].source, &size, sha)) {
+ ok = fail(err, errSize, "Cannot read %s.", sorted[i].source);
+ } else if (!pkgManifestAddFile(&m, sorted[i].name, size, sha)) {
+ ok = fail(err, errSize, "There is not enough memory.");
+ }
+ }
+
+ ok = ok && pkgManifestCheck(&m, err, errSize);
+ text = ok ? pkgManifestFormat(&m, &len) : NULL;
+
+ if (ok && !text) {
+ ok = fail(err, errSize, "There is not enough memory.");
+ }
+
+ PkgZipWriterT *w = ok ? pkgZipCreate(outPath) : NULL;
+
+ if (ok && !w) {
+ ok = fail(err, errSize, "Cannot create %s.", outPath);
+ }
+
+ if (ok && !pkgZipAddMem(w, PKG_MANIFEST_NAME, text, (uint32_t)len, time(NULL))) {
+ ok = fail(err, errSize, "Cannot write %s.", outPath);
+ }
+
+ if (ok && secret) {
+ PkgSigT sig;
+ int32_t sigLen = 0;
+ char *sigText = NULL;
+
+ pkgSign(secret, (const uint8_t *)text, len, &sig);
+ sigText = pkgSigFormat(&sig, &sigLen);
+
+ if (!sigText || !pkgZipAddMem(w, PKG_SIG_NAME, sigText, (uint32_t)sigLen, time(NULL))) {
+ ok = fail(err, errSize, "Cannot write %s.", outPath);
+ }
+
+ free(sigText);
+ }
+
+ for (int32_t i = 0; ok && i < (int32_t)arrlen(sorted); i++) {
+ if (!pkgZipAddFile(w, sorted[i].name, sorted[i].source)) {
+ ok = fail(err, errSize, "Cannot add %s to %s.", sorted[i].source, outPath);
+ }
+ }
+
+ if (w && ok && !pkgZipFinish(w)) {
+ ok = fail(err, errSize, "Cannot write %s.", outPath);
+ } else if (w && !ok) {
+ pkgZipAbort(w);
+ }
+
+ free(text);
+ arrfree(sorted);
+ pkgManifestFree(&m);
+ return ok;
+}
+
+
+bool pkgDpkCheckFiles(const char *path, const PkgDpkInfoT *info, char *err, int32_t errSize) {
+ const PkgManifestT *m = &info->manifest;
+ int32_t count = (int32_t)arrlen(m->files);
+ bool *seen = (bool *)calloc((size_t)count + 1, sizeof(bool));
+ FILE *f = seen ? fopen(path, "rb") : NULL;
+ PkgZipT *zip = f ? pkgZipOpen(fileRead, f) : NULL;
+ bool ok = zip != NULL;
+ PkgZipEntryT e;
+ PkgZipStepE step = PkgZipFailE;
+
+ if (!ok) {
+ ok = fail(err, errSize, "Cannot read %s.", path);
+ }
+
+ while (ok && (step = pkgZipNext(zip, &e)) == PkgZipEntryE) {
+ if (e.isDir) {
+ continue;
+ }
+
+ // The manifest and signature: not files of the package
+ if (strcasecmp(e.name, PKG_MANIFEST_NAME) == 0 || strcasecmp(e.name, PKG_SIG_NAME) == 0) {
+ ok = pkgZipData(zip, NULL, NULL, INT32_MAX) == PkgZipFileDoneE || fail(err, errSize, "%s", zipReason(zip));
+ continue;
+ }
+
+ const PkgFileT *file = pkgManifestFindFile(m, e.name);
+
+ if (!file) {
+ ok = fail(err, errSize, "%s is in the package but not in its manifest.", e.name);
+ break;
+ }
+
+ int32_t index = (int32_t)(file - m->files);
+
+ if (seen[index]) {
+ ok = fail(err, errSize, "%s is in the package twice.", e.name);
+ break;
+ }
+
+ seen[index] = true;
+
+ SecSha256T ctx;
+ uint8_t sha[PKG_SHA256_SIZE];
+ PkgZipStepE data;
+
+ secSha256Init(&ctx);
+
+ while ((data = pkgZipData(zip, hashWrite, &ctx, ENTRY_STEP)) == PkgZipMoreE) {
+ }
+
+ secSha256Final(&ctx, sha);
+
+ if (data != PkgZipFileDoneE) {
+ ok = fail(err, errSize, "%s: %s", e.name, zipReason(zip));
+ } else if (e.size != file->size || memcmp(sha, file->sha256, PKG_SHA256_SIZE) != 0) {
+ ok = fail(err, errSize, "%s is not the file the manifest lists (it was changed or damaged).", e.name);
+ }
+ }
+
+ if (ok && step == PkgZipFailE) {
+ ok = fail(err, errSize, "%s", zipReason(zip));
+ }
+
+ for (int32_t i = 0; ok && i < count; i++) {
+ if (!seen[i]) {
+ ok = fail(err, errSize, "%s is in the manifest but not in the package.", m->files[i].name);
+ }
+ }
+
+ if (zip) {
+ pkgZipClose(zip);
+ }
+
+ if (f) {
+ fclose(f);
+ }
+
+ free(seen);
+ return ok;
+}
+
+
+void pkgDpkFilesFree(PkgDpkFileT *files) {
+ for (int32_t i = 0; i < (int32_t)arrlen(files); i++) {
+ free(files[i].name);
+ free(files[i].source);
+ }
+
+ arrfree(files);
+}
+
+
+bool pkgDpkFolderFiles(const char *folder, const char *prefix, PkgDpkFileT **files) {
+ bool ok = walk(folder, "", prefix ? prefix : "", files);
+
+ sortFiles(*files);
+ return ok;
+}
+
+
+void pkgDpkInfoFree(PkgDpkInfoT *info) {
+ pkgManifestFree(&info->manifest);
+ free(info->text);
+ info->text = NULL;
+ info->textLen = 0;
+}
+
+
+bool pkgDpkReadInfo(const char *path, PkgDpkInfoT *info, char *err, int32_t errSize) {
+ FILE *f = fopen(path, "rb");
+ PkgZipT *zip = f ? pkgZipOpen(fileRead, f) : NULL;
+ MemSinkT text = { NULL, 0, 0 };
+ PkgZipEntryT e;
+ bool ok = zip != NULL;
+
+ memset(info, 0, sizeof(*info));
+
+ if (!ok) {
+ ok = fail(err, errSize, "Cannot read %s.", path);
+ } else if (pkgZipNext(zip, &e) != PkgZipEntryE || e.isDir || strcasecmp(e.name, PKG_MANIFEST_NAME) != 0) {
+ ok = fail(err, errSize, "%s is not a DVX package (it does not start with %s).", path, PKG_MANIFEST_NAME);
+ } else if (!readEntry(zip, &e, PKG_MANIFEST_MAX, &text)) {
+ ok = fail(err, errSize, "The manifest of %s cannot be read: %s", path, zipReason(zip));
+ } else if (!pkgManifestParse((const char *)text.buf, (int32_t)text.len, &info->manifest, err, errSize)) {
+ ok = false;
+ } else if (!pkgManifestCheck(&info->manifest, err, errSize)) {
+ pkgManifestFree(&info->manifest);
+ ok = false;
+ }
+
+ // The publisher's signature, when it is the next entry
+ if (ok && pkgZipNext(zip, &e) == PkgZipEntryE && !e.isDir && strcasecmp(e.name, PKG_SIG_NAME) == 0) {
+ MemSinkT sig = { NULL, 0, 0 };
+
+ if (!readEntry(zip, &e, SIG_TEXT_MAX, &sig)) {
+ ok = fail(err, errSize, "The signature of %s cannot be read.", path);
+ } else {
+ ok = pkgSigParse((const char *)sig.buf, (int32_t)sig.len, &info->sig, err, errSize);
+ }
+
+ if (ok) {
+ info->hasSig = true;
+ } else {
+ pkgManifestFree(&info->manifest);
+ }
+
+ free(sig.buf);
+ }
+
+ if (zip) {
+ pkgZipClose(zip);
+ }
+
+ if (f) {
+ fclose(f);
+ }
+
+ if (!ok) {
+ free(text.buf);
+ return false;
+ }
+
+ info->text = (char *)text.buf;
+ info->textLen = (int32_t)text.len;
+ return true;
+}
+
+
+bool pkgDpkSign(const char *path, const PkgSecretT *secret, bool detached, char *err, int32_t errSize) {
+ PkgDpkInfoT info;
+ PkgSigT sig;
+ int32_t sigLen = 0;
+ char *sigText = NULL;
+ char other[DVX_MAX_PATH];
+ bool ok;
+
+ if (!pkgDpkReadInfo(path, &info, err, errSize)) {
+ return false;
+ }
+
+ pkgSign(secret, (const uint8_t *)info.text, info.textLen, &sig);
+ sigText = pkgSigFormat(&sig, &sigLen);
+ ok = sigText != NULL || fail(err, errSize, "There is not enough memory.");
+
+ if (ok && !siblingPath(path, detached ? PKG_SIG_EXT : TEMP_EXT, other, sizeof(other))) {
+ ok = fail(err, errSize, "The path of %s is too long.", path);
+ }
+
+ if (ok && detached) {
+ FILE *f = fopen(other, "wb");
+
+ ok = f && fwrite(sigText, 1, (size_t)sigLen, f) == (size_t)sigLen;
+ ok = (f && fclose(f) == 0 && ok) || fail(err, errSize, "Cannot write %s.", other);
+ } else if (ok) {
+ // Rewrite the package: the manifest, the new signature, then every
+ // file as it was (an old signature left out)
+ FILE *f = fopen(path, "rb");
+ PkgZipT *zip = f ? pkgZipOpen(fileRead, f) : NULL;
+ PkgZipWriterT *w = zip ? pkgZipCreate(other) : NULL;
+ PkgZipEntryT e;
+ PkgZipStepE step = PkgZipFailE;
+
+ ok = w && pkgZipAddMem(w, PKG_MANIFEST_NAME, info.text, (uint32_t)info.textLen, time(NULL)) && pkgZipAddMem(w, PKG_SIG_NAME, sigText, (uint32_t)sigLen, time(NULL));
+
+ while (ok && (step = pkgZipNext(zip, &e)) == PkgZipEntryE) {
+ MemSinkT data = { NULL, 0, 0 };
+ bool keep = !e.isDir && strcasecmp(e.name, PKG_MANIFEST_NAME) != 0 && strcasecmp(e.name, PKG_SIG_NAME) != 0;
+
+ ok = readEntry(zip, &e, e.size, &data) && (!keep || pkgZipAddMem(w, e.name, data.buf, data.len, pkgZipDosTime(e.dosDate, e.dosTime)));
+ free(data.buf);
+ }
+
+ ok = ok && step == PkgZipEndE;
+
+ if (zip) {
+ pkgZipClose(zip);
+ }
+
+ if (f) {
+ fclose(f);
+ }
+
+ if (!ok) {
+ pkgZipAbort(w);
+ ok = fail(err, errSize, "Cannot rewrite %s.", path);
+ } else if (!pkgZipFinish(w)) {
+ ok = fail(err, errSize, "Cannot write %s.", other);
+ } else {
+ ok = (remove(path) == 0 && rename(other, path) == 0) || fail(err, errSize, "Cannot replace %s (the signed copy is %s).", path, other);
+ }
+ }
+
+ free(sigText);
+ pkgDpkInfoFree(&info);
+ return ok;
+}
+
+
+// A signature names its key, so only that key's check is made (each is
+// slow on a 486).
+const PkgKeyT *pkgDpkSigner(const PkgDpkInfoT *info, const PkgSigT *detached, const PkgKeyT *keys, int32_t count) {
+ const uint8_t *msg = (const uint8_t *)info->text;
+
+ for (int32_t i = 0; i < count; i++) {
+ bool own = info->hasSig && memcmp(info->sig.keyId, keys[i].id, PKG_KEY_ID_SIZE) == 0 && pkgSigCheck(&info->sig, &keys[i], msg, info->textLen);
+ bool apart = !own && detached && memcmp(detached->keyId, keys[i].id, PKG_KEY_ID_SIZE) == 0 && pkgSigCheck(detached, &keys[i], msg, info->textLen);
+
+ if (own || apart) {
+ return &keys[i];
+ }
+ }
+
+ return NULL;
+}
+
+
+// The whole of entry e (at most max bytes) into a new buffer in sink (the
+// caller frees sink->buf).
+static bool readEntry(PkgZipT *zip, const PkgZipEntryT *e, uint32_t max, MemSinkT *sink) {
+ if (e->size > max) {
+ return false;
+ }
+
+ sink->buf = (uint8_t *)malloc(e->size ? e->size : 1);
+ sink->len = 0;
+ sink->cap = e->size;
+
+ if (!sink->buf) {
+ return false;
+ }
+
+ PkgZipStepE step;
+
+ while ((step = pkgZipData(zip, memWrite, sink, ENTRY_STEP)) == PkgZipMoreE) {
+ }
+
+ return step == PkgZipFileDoneE;
+}
+
+
+// True when a and b name the same existing file.
+static bool sameFile(const char *a, const char *b) {
+ struct stat sa;
+ struct stat sb;
+
+ return stat(a, &sa) == 0 && stat(b, &sb) == 0 && sa.st_ino == sb.st_ino && sa.st_dev == sb.st_dev && sa.st_ino != 0;
+}
+
+
+// path with its extension (if any) replaced by ext.
+static bool siblingPath(const char *path, const char *ext, char *out, int32_t outSize) {
+ const char *base = platformPathBaseName(path);
+ const char *dot = strrchr(base, DOT);
+ int32_t keep = dot ? (int32_t)(dot - path) : (int32_t)strlen(path);
+ int32_t n = snprintf(out, (size_t)outSize, "%.*s%s", (int)keep, path, ext);
+
+ return n > 0 && n < outSize;
+}
+
+
+// files (stb_ds) in name order (qsort is not given an empty one: it
+// would be NULL).
+static void sortFiles(PkgDpkFileT *files) {
+ if (arrlen(files) > 1) {
+ qsort(files, (size_t)arrlen(files), sizeof(*files), compareFiles);
+ }
+}
+
+
+// Every file under folder/rel onto *files, named prefix/rel/... ('/'
+// between parts; rel/... with no prefix).
+static bool walk(const char *folder, const char *rel, const char *prefix, PkgDpkFileT **files) {
+ char dir[DVX_MAX_PATH];
+
+ if (!platformPathJoin(dir, sizeof(dir), folder, rel)) {
+ return false;
+ }
+
+ DIR *d = opendir(dir);
+
+ if (!d) {
+ return false;
+ }
+
+ bool ok = true;
+ struct dirent *ent;
+
+ while (ok && (ent = readdir(d)) != NULL) {
+ char sub[PKG_NAME_MAX];
+ char name[PKG_NAME_MAX];
+ char full[DVX_MAX_PATH];
+ struct stat st;
+
+ if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) {
+ continue;
+ }
+
+ // The template and a signature at the top are not package files
+ if (!rel[0] && (strcasecmp(ent->d_name, PKG_MANIFEST_NAME) == 0 || strcasecmp(ent->d_name, PKG_SIG_NAME) == 0)) {
+ continue;
+ }
+
+ if (!joinName(sub, sizeof(sub), rel, ent->d_name) || !joinName(name, sizeof(name), prefix, sub) || !platformPathJoin(full, sizeof(full), folder, sub) || stat(full, &st) != 0) {
+ ok = false;
+ } else if (S_ISDIR(st.st_mode)) {
+ ok = walk(folder, sub, prefix, files);
+ } else {
+ PkgDpkFileT f = { strdup(name), strdup(full) };
+
+ ok = f.name && f.source;
+
+ if (ok) {
+ arrput(*files, f);
+ } else {
+ free(f.name);
+ free(f.source);
+ }
+ }
+ }
+
+ closedir(d);
+ return ok;
+}
+
+
+static const char *zipReason(const PkgZipT *zip) {
+ switch (pkgZipProblem(zip)) {
+ case PkgZipReadFailedE:
+ return "it cannot be read.";
+
+ case PkgZipUnreadableE:
+ return "it uses a form DVX cannot read.";
+
+ case PkgZipWriteFailedE:
+ return "there is not enough memory, or a part is too big.";
+
+ default:
+ return "it is damaged.";
+ }
+}
diff --git a/src/libs/kpunch/pkg/pkgDpk.h b/src/libs/kpunch/pkg/pkgDpk.h
new file mode 100644
index 00000000..0b2110dc
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgDpk.h
@@ -0,0 +1,103 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgDpk.h -- add-on package files (NAME.DPK): building one from a
+// folder, reading its manifest and signature, checking every file, and
+// signing one.
+//
+// The ZIP holds PACKAGE.INI first, then PACKAGE.SIG when the publisher
+// signed it, then the files (pkgManifest.h). Building takes a manifest
+// without [files] (the template) and the files: those under a folder laid
+// out like the DVX folder (APPS\VENDOR\NAME\..., SHARED\FONTS\VENDOR\...),
+// named relative to it, or a list of names and the files they come from.
+// They go in sorted by name, and the manifest is completed with each
+// one's size and SHA-256 and checked before anything is written.
+
+#ifndef PKG_DPK_H
+#define PKG_DPK_H
+
+#include "pkgManifest.h"
+#include "pkgSign.h"
+
+#include
+#include
+
+#define PKG_MANIFEST_MAX (256 * 1024) // largest manifest read
+
+// What a package says about itself (its first entries)
+typedef struct {
+ PkgManifestT manifest;
+ char *text; // the manifest's bytes, as signed (malloc'd)
+ int32_t textLen;
+ bool hasSig; // PACKAGE.SIG was there
+ PkgSigT sig;
+} PkgDpkInfoT;
+
+// A file to build into a package: its name there (relative to the DVX
+// folder, '/' between parts) and the file it is made from.
+typedef struct {
+ char *name;
+ char *source;
+} PkgDpkFileT;
+
+// Build outPath from a template manifest (its [files], if any, is
+// replaced; a program, library, or widget with no abi gets this DVX's,
+// DVX_ABI_VERSION) and the files under folder (pkgDpkFolderFiles, no
+// prefix), signed with secret when not NULL. False with the reason in
+// err (and no file left behind).
+bool pkgDpkBuild(const PkgManifestT *templ, const char *folder, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize);
+
+// The same from files (count of them, any order; one that is outPath is
+// left out).
+bool pkgDpkBuildFiles(const PkgManifestT *templ, const PkgDpkFileT *files, int32_t count, const char *outPath, const PkgSecretT *secret, char *err, int32_t errSize);
+
+// Check every file of the package at path against info's manifest: each
+// entry listed, of its size and SHA-256, and each listed file there.
+// False with the reason in err.
+bool pkgDpkCheckFiles(const char *path, const PkgDpkInfoT *info, char *err, int32_t errSize);
+
+void pkgDpkFilesFree(PkgDpkFileT *files);
+
+// Every file under folder (but a PACKAGE.INI or PACKAGE.SIG at its top)
+// onto *files (stb_ds; pkgDpkFilesFree), named prefix/ ('/' between parts; no prefix when it is "" or NULL); *files is
+// then in name order. False when a folder cannot be read or memory runs
+// out.
+bool pkgDpkFolderFiles(const char *folder, const char *prefix, PkgDpkFileT **files);
+
+void pkgDpkInfoFree(PkgDpkInfoT *info);
+
+// Read the manifest (and the publisher's signature, if any) of the
+// package at path, and check the manifest (pkgManifestCheck). False with
+// the reason in err (nothing to free).
+bool pkgDpkReadInfo(const char *path, PkgDpkInfoT *info, char *err, int32_t errSize);
+
+// Sign the package at path with secret: into it (PACKAGE.SIG, replacing
+// one that is there; the package is rewritten) or, when detached, as
+// NAME.SIG beside it. False with the reason in err.
+bool pkgDpkSign(const char *path, const PkgSecretT *secret, bool detached, char *err, int32_t errSize);
+
+// The one of keys (count of them) that made info's signature or the
+// detached one (NULL: none), or NULL when neither is by any of them.
+const PkgKeyT *pkgDpkSigner(const PkgDpkInfoT *info, const PkgSigT *detached, const PkgKeyT *keys, int32_t count);
+
+#endif // PKG_DPK_H
diff --git a/src/installer/pkgFormat.c b/src/libs/kpunch/pkg/pkgFormat.c
similarity index 88%
rename from src/installer/pkgFormat.c
rename to src/libs/kpunch/pkg/pkgFormat.c
index 103a1b80..17679f14 100644
--- a/src/installer/pkgFormat.c
+++ b/src/libs/kpunch/pkg/pkgFormat.c
@@ -77,8 +77,8 @@ bool pkgLocalPath(const char *dir, const char *relName, char *out, in
bool pkgNameIsValid(const char *name);
bool pkgPartName(int32_t disk, char *out, int32_t outSize);
int32_t pkgPartNumber(const char *name);
-static void put16(uint8_t *p, uint16_t v);
-static void put32(uint8_t *p, uint32_t v);
+void pkgPut16(uint8_t *p, uint16_t v);
+void pkgPut32(uint8_t *p, uint32_t v);
// One name part: base of 1-8 characters, optional '.' and 1-3 more.
@@ -113,7 +113,7 @@ uint16_t pkgGet16(const uint8_t *p) {
uint32_t pkgGet32(const uint8_t *p) {
- return (uint32_t)pkgGet16(p) | ((uint32_t)pkgGet16(p + 2) << HALF_BITS);
+ return (uint32_t)pkgGet16(p) | ((uint32_t)pkgGet16(p + sizeof(uint16_t)) << HALF_BITS);
}
@@ -155,25 +155,25 @@ bool pkgHeaderDecode(const uint8_t *in, PkgHeaderT *h) {
void pkgHeaderEncode(const PkgHeaderT *h, uint8_t *out) {
memset(out, 0, PKG_HEADER_SIZE);
memcpy(out, PKG_MAGIC, PKG_MAGIC_SIZE);
- put16(out + HDR_VERSION, PKG_FORMAT_VERSION);
- put16(out + HDR_DISK, h->disk);
- put16(out + HDR_DISK_COUNT, h->diskCount);
- put32(out + HDR_SET_ID, h->setId);
- put32(out + HDR_PART_OFFSET, h->partOffset);
- put32(out + HDR_PART_LENGTH, h->partLength);
- put32(out + HDR_ZIP_SIZE, h->zipSize);
+ pkgPut16(out + HDR_VERSION, PKG_FORMAT_VERSION);
+ pkgPut16(out + HDR_DISK, h->disk);
+ pkgPut16(out + HDR_DISK_COUNT, h->diskCount);
+ pkgPut32(out + HDR_SET_ID, h->setId);
+ pkgPut32(out + HDR_PART_OFFSET, h->partOffset);
+ pkgPut32(out + HDR_PART_LENGTH, h->partLength);
+ pkgPut32(out + HDR_ZIP_SIZE, h->zipSize);
for (int32_t t = 0; t < PKG_TIERS; t++) {
uint8_t *p = out + HDR_TIERS + t * HDR_TIER_SIZE;
const PkgTierT *tier = &h->tiers[t];
- put32(p + TIER_END, tier->end);
- put32(p + TIER_FILES, tier->fileCount);
- put32(p + TIER_BYTES, (uint32_t)tier->bytes);
- put32(p + TIER_BYTES_HI, (uint32_t)(tier->bytes >> WORD_BITS));
+ pkgPut32(p + TIER_END, tier->end);
+ pkgPut32(p + TIER_FILES, tier->fileCount);
+ pkgPut32(p + TIER_BYTES, (uint32_t)tier->bytes);
+ pkgPut32(p + TIER_BYTES_HI, (uint32_t)(tier->bytes >> WORD_BITS));
}
- put32(out + HDR_CRC, (uint32_t)mz_crc32(MZ_CRC32_INIT, out, HDR_CRC));
+ pkgPut32(out + HDR_CRC, (uint32_t)mz_crc32(MZ_CRC32_INIT, out, HDR_CRC));
}
@@ -273,13 +273,13 @@ int32_t pkgPartNumber(const char *name) {
}
-static void put16(uint8_t *p, uint16_t v) {
+void pkgPut16(uint8_t *p, uint16_t v) {
p[0] = (uint8_t)(v & BYTE_MASK);
p[1] = (uint8_t)(v >> BYTE_BITS);
}
-static void put32(uint8_t *p, uint32_t v) {
- put16(p, (uint16_t)(v & HALF_MASK));
- put16(p + 2, (uint16_t)(v >> HALF_BITS));
+void pkgPut32(uint8_t *p, uint32_t v) {
+ pkgPut16(p, (uint16_t)(v & HALF_MASK));
+ pkgPut16(p + sizeof(uint16_t), (uint16_t)(v >> HALF_BITS));
}
diff --git a/src/installer/pkgFormat.h b/src/libs/kpunch/pkg/pkgFormat.h
similarity index 80%
rename from src/installer/pkgFormat.h
rename to src/libs/kpunch/pkg/pkgFormat.h
index 6627b779..6818a8a1 100644
--- a/src/installer/pkgFormat.h
+++ b/src/libs/kpunch/pkg/pkgFormat.h
@@ -47,7 +47,8 @@
// every file's CRC-32. Every name is a relative path of DOS 8.3 parts
// separated by '/'.
//
-// Shared by SETUP.EXE (pkgRead.c), the package tool (pkgTool.c) and the
+// Shared by SETUP.EXE (pkgRead.c), the package library's ZIP reader and
+// writer (pkgZip.c, pkgZipWrite.c), the package tool (pkgTool.c), and the
// host tests.
#ifndef PKG_FORMAT_H
@@ -74,10 +75,45 @@
#define PKG_PART_NAME_MAX 8
#define PKG_MAX_DISKS 99
-// ZIP record signatures and sizes (APPNOTE 4.3.7, 4.3.12)
+// ZIP record signatures and sizes (APPNOTE 4.3.7, 4.3.12, 4.3.16)
#define PKG_ZIP_LOCAL_SIG 0x04034b50u
#define PKG_ZIP_CENTRAL_SIG 0x02014b50u
+#define PKG_ZIP_EOCD_SIG 0x06054b50u
#define PKG_ZIP_LOCAL_SIZE 30
+#define PKG_ZIP_CENTRAL_SIZE 46
+#define PKG_ZIP_EOCD_SIZE 22
+#define PKG_ZIP_SIG_SIZE 4
+#define PKG_ZIP_VERSION 20 // made by and needed: 2.0 (deflate), MS-DOS
+
+// Local header fields (offsets from its first byte)
+#define PKG_ZIP_LH_NEEDED 4
+#define PKG_ZIP_LH_FLAGS 6
+#define PKG_ZIP_LH_METHOD 8
+#define PKG_ZIP_LH_TIME 10
+#define PKG_ZIP_LH_DATE 12
+#define PKG_ZIP_LH_CRC 14
+#define PKG_ZIP_LH_PACKED 18
+#define PKG_ZIP_LH_SIZE 22
+#define PKG_ZIP_LH_NAME_LEN 26
+#define PKG_ZIP_LH_EXTRA_LEN 28
+
+// Central directory record fields
+#define PKG_ZIP_CD_MADE_BY 4
+#define PKG_ZIP_CD_NEEDED 6
+#define PKG_ZIP_CD_METHOD 10
+#define PKG_ZIP_CD_TIME 12
+#define PKG_ZIP_CD_DATE 14
+#define PKG_ZIP_CD_CRC 16
+#define PKG_ZIP_CD_PACKED 20
+#define PKG_ZIP_CD_SIZE 24
+#define PKG_ZIP_CD_NAME_LEN 28
+#define PKG_ZIP_CD_OFFSET 42
+
+// End of central directory fields
+#define PKG_ZIP_EOCD_DISK_ENTRIES 8
+#define PKG_ZIP_EOCD_ENTRIES 10
+#define PKG_ZIP_EOCD_DIR_SIZE 12
+#define PKG_ZIP_EOCD_DIR_OFFSET 16
#define PKG_ZIP_STORED 0
#define PKG_ZIP_DEFLATED 8
#define PKG_ZIP_FLAG_DESC 0x0008 // sizes after the data: not allowed here
@@ -136,8 +172,10 @@ bool pkgPartName(int32_t disk, char *out, int32_t outSize);
// one.
int32_t pkgPartNumber(const char *name);
-// Little-endian field access (shared with the ZIP reader)
+// Little-endian field access (shared with the ZIP reader and writer)
uint16_t pkgGet16(const uint8_t *p);
uint32_t pkgGet32(const uint8_t *p);
+void pkgPut16(uint8_t *p, uint16_t v);
+void pkgPut32(uint8_t *p, uint32_t v);
#endif // PKG_FORMAT_H
diff --git a/src/libs/kpunch/pkg/pkgIni.c b/src/libs/kpunch/pkg/pkgIni.c
new file mode 100644
index 00000000..335fdd06
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgIni.c
@@ -0,0 +1,175 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgIni.c -- the package library's INI reader and hex (see pkgInt.h)
+
+#include "pkgInt.h"
+
+#include
+#include
+
+#define HEX_DIGITS "0123456789abcdef"
+#define HEX_RADIX 16
+#define HEX_PER_BYTE 2
+#define HEX_LETTER_BASE 10 // the value of 'a'
+#define SECTION_BRACKETS 2 // '[' and ']'
+#define NIBBLE_BITS 4
+#define NIBBLE_MASK 0x0F
+#define COMMENT_SEMICOLON ';'
+#define COMMENT_HASH '#'
+#define SECTION_OPEN '['
+#define SECTION_CLOSE ']'
+#define KEY_SEP '='
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static int32_t hexValue(char c);
+bool pkgHexDecode(const char *text, uint8_t *bytes, int32_t n);
+void pkgHexEncode(const uint8_t *bytes, int32_t n, char *out);
+PkgIniStepE pkgIniNext(PkgIniT *ini);
+void pkgIniStart(PkgIniT *ini, const char *text, int32_t len);
+static bool trimCopy(char *out, int32_t outSize, const char *from, int32_t len);
+
+
+static int32_t hexValue(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+
+ c = (char)tolower((unsigned char)c);
+ return (c >= 'a' && c <= 'f') ? c - 'a' + HEX_LETTER_BASE : -1;
+}
+
+
+bool pkgHexDecode(const char *text, uint8_t *bytes, int32_t n) {
+ if ((int32_t)strlen(text) != n * HEX_PER_BYTE) {
+ return false;
+ }
+
+ for (int32_t i = 0; i < n; i++) {
+ int32_t hi = hexValue(text[i * HEX_PER_BYTE]);
+ int32_t lo = hexValue(text[i * HEX_PER_BYTE + 1]);
+
+ if (hi < 0 || lo < 0) {
+ return false;
+ }
+
+ bytes[i] = (uint8_t)(hi * HEX_RADIX + lo);
+ }
+
+ return true;
+}
+
+
+void pkgHexEncode(const uint8_t *bytes, int32_t n, char *out) {
+ for (int32_t i = 0; i < n; i++) {
+ out[i * HEX_PER_BYTE] = HEX_DIGITS[bytes[i] >> NIBBLE_BITS];
+ out[i * HEX_PER_BYTE + 1] = HEX_DIGITS[bytes[i] & NIBBLE_MASK];
+ }
+
+ out[n * HEX_PER_BYTE] = '\0';
+}
+
+
+PkgIniStepE pkgIniNext(PkgIniT *ini) {
+ while (ini->pos < ini->len) {
+ const char *start = ini->text + ini->pos;
+ int32_t left = ini->len - ini->pos;
+ int32_t len = 0;
+
+ while (len < left && start[len] != '\n') {
+ len++;
+ }
+
+ ini->pos += len + (len < left ? 1 : 0);
+ ini->line++;
+
+ // The line without its CR and the blanks around it
+ int32_t first = 0;
+ int32_t last = len;
+
+ while (last > first && isspace((unsigned char)start[last - 1])) {
+ last--;
+ }
+
+ while (first < last && isspace((unsigned char)start[first])) {
+ first++;
+ }
+
+ if (first == last || start[first] == COMMENT_SEMICOLON || start[first] == COMMENT_HASH) {
+ continue;
+ }
+
+ if (start[first] == SECTION_OPEN) {
+ if (start[last - 1] != SECTION_CLOSE || !trimCopy(ini->section, sizeof(ini->section), start + first + 1, last - first - SECTION_BRACKETS)) {
+ return PkgIniBadE;
+ }
+
+ for (char *p = ini->section; *p; p++) {
+ *p = (char)tolower((unsigned char)*p);
+ }
+
+ return PkgIniSectionE;
+ }
+
+ const char *eq = memchr(start + first, KEY_SEP, (size_t)(last - first));
+
+ if (!eq || !trimCopy(ini->key, sizeof(ini->key), start + first, (int32_t)(eq - (start + first))) || !ini->key[0] || !trimCopy(ini->value, sizeof(ini->value), eq + 1, (int32_t)(start + last - (eq + 1)))) {
+ return PkgIniBadE;
+ }
+
+ return PkgIniKeyE;
+ }
+
+ return PkgIniEndE;
+}
+
+
+void pkgIniStart(PkgIniT *ini, const char *text, int32_t len) {
+ memset(ini, 0, sizeof(*ini));
+ ini->text = text;
+ ini->len = len;
+}
+
+
+// len bytes from from, without the blanks around them, as a C string;
+// false when they do not fit.
+static bool trimCopy(char *out, int32_t outSize, const char *from, int32_t len) {
+ while (len > 0 && isspace((unsigned char)from[0])) {
+ from++;
+ len--;
+ }
+
+ while (len > 0 && isspace((unsigned char)from[len - 1])) {
+ len--;
+ }
+
+ if (len < 0 || len >= outSize) {
+ return false;
+ }
+
+ memcpy(out, from, (size_t)len);
+ out[len] = '\0';
+ return true;
+}
diff --git a/src/libs/kpunch/pkg/pkgInt.h b/src/libs/kpunch/pkg/pkgInt.h
new file mode 100644
index 00000000..be7bf04e
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgInt.h
@@ -0,0 +1,67 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgInt.h -- the package library's own helpers: reading the small INI
+// texts of manifests, keys, and signatures (one strict reader for all of
+// them), and hex.
+
+#ifndef PKG_INT_H
+#define PKG_INT_H
+
+#include "pkgFormat.h"
+
+#include
+#include
+
+#define PKG_INI_SECTION_MAX 32
+#define PKG_INI_VALUE_MAX 160
+
+typedef enum {
+ PkgIniSectionE, // a [section] line: ini->section
+ PkgIniKeyE, // a key = value line: ini->key, ini->value (trimmed)
+ PkgIniEndE,
+ PkgIniBadE // a line that is neither, or too long: ini->line
+} PkgIniStepE;
+
+typedef struct {
+ const char *text;
+ int32_t len;
+ int32_t pos;
+ int32_t line; // of the last line read, from 1
+ char section[PKG_INI_SECTION_MAX]; // the last [section] (lower case)
+ char key[PKG_NAME_MAX];
+ char value[PKG_INI_VALUE_MAX];
+} PkgIniT;
+
+// Bytes as lower-case hex digits (out holds 2 * n + 1).
+void pkgHexEncode(const uint8_t *bytes, int32_t n, char *out);
+
+// Exactly n bytes from 2 * n hex digits (either case) and nothing more.
+bool pkgHexDecode(const char *text, uint8_t *bytes, int32_t n);
+
+// The next section or key line (blank lines and lines starting with ';'
+// or '#' are skipped; a line ends at CR LF or LF).
+PkgIniStepE pkgIniNext(PkgIniT *ini);
+
+void pkgIniStart(PkgIniT *ini, const char *text, int32_t len);
+
+#endif // PKG_INT_H
diff --git a/src/libs/kpunch/pkg/pkgManifest.c b/src/libs/kpunch/pkg/pkgManifest.c
new file mode 100644
index 00000000..91e28cd0
--- /dev/null
+++ b/src/libs/kpunch/pkg/pkgManifest.c
@@ -0,0 +1,817 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// pkgManifest.c -- reading, checking, and writing a package's manifest
+// (see pkgManifest.h)
+
+#include "pkgManifest.h"
+#include "pkgInt.h"
+#include "dvxPlat.h"
+#include "stb_ds_wrap.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ABI stamp checked by the loader (see DVX_ABI_VERSION)
+DVX_ABI_STAMP(pkg);
+
+#define SECTION_PACKAGE "package"
+#define SECTION_DEPENDS "depends"
+#define SECTION_FILES "files"
+#define VERSION_PARTS 3
+#define VERSION_PART_MAX 65535
+#define DECIMAL 10
+#define ID_EXTRA_CHAR '_' // the one character an id part takes besides a-z and 0-9
+#define PREFIX_MAX (PKG_NAME_MAX)
+#define TEXT_FIRST 512 // the formatted manifest's first capacity
+#define LINE_MAX (PKG_NAME_MAX + PKG_SHA256_SIZE * 2 + 32)
+#define EOL "\r\n"
+#define SHA_HEX_SIZE (PKG_SHA256_SIZE * 2 + 1)
+#define PATH_SEP_TEXT "/"
+
+// The keys of [package] (sPackageKeys order)
+typedef enum {
+ KeyFormatE,
+ KeyIdE,
+ KeyNameE,
+ KeyVersionE,
+ KeyKindE,
+ KeySummaryE,
+ KeyPublisherE,
+ KeyAuthorE,
+ KeyLicenceE,
+ KeyHomepageE,
+ KeyDvxE,
+ KeyAbiE,
+ KeyCountE
+} PackageKeyE;
+
+// The folders a kind may write: /// when perName,
+// else //
+typedef struct {
+ const char *root;
+ bool perName;
+} KindFolderT;
+
+// A growing text
+typedef struct {
+ char *text;
+ int32_t len;
+ int32_t cap;
+ bool failed;
+} TextT;
+
+static const char *const sPackageKeys[KeyCountE] = { "format", "id", "name", "version", "kind", "summary", "publisher", "author", "licence", "homepage", "dvx", "abi" };
+
+// The keys a manifest must have
+#define REQUIRED_KEYS ((1u << KeyFormatE) | (1u << KeyIdE) | (1u << KeyNameE) | (1u << KeyVersionE) | (1u << KeyKindE))
+
+// Kind names (PkgKindE order)
+static const char *const sKindNames[PkgKindCountE] = { "app", "library", "widget", "font", "bank", "theme", "wallpaper", "sound", "help", "bundle", "system" };
+
+// Each kind's folders; help goes in its program's folder, or beside a
+// library or widget of its name (pkgFileAllowed); bundle has none; system
+// has the whole program tree.
+static const KindFolderT sKindFolders[PkgKindCountE] = {
+ [PkgKindAppE] = { DVX_APPS_DIR, true },
+ [PkgKindLibraryE] = { DVX_LIBS_DIR, true },
+ [PkgKindWidgetE] = { DVX_WIDGETS_DIR, true },
+ [PkgKindFontE] = { DVX_SHARED_DIR "/" DVX_FONTS_DIR, false },
+ [PkgKindBankE] = { DVX_SHARED_DIR "/" DVX_BANKS_DIR, false },
+ [PkgKindThemeE] = { DVX_SHARED_DIR "/" DVX_THEMES_DIR, false },
+ [PkgKindWallpaperE] = { DVX_SHARED_DIR "/" DVX_WPAPER_DIR, false },
+ [PkgKindSoundE] = { DVX_SHARED_DIR "/" DVX_SOUNDS_DIR, false },
+ [PkgKindHelpE] = { DVX_APPS_DIR, true },
+ [PkgKindBundleE] = { NULL, false },
+ [PkgKindSystemE] = { NULL, false }
+};
+
+// ============================================================
+// Prototypes
+// ============================================================
+
+static void copyText(char *out, int32_t outSize, const char *value, int32_t line, bool *ok, char *err, int32_t errSize);
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) __attribute__((format(printf, 3, 4)));
+static int32_t folderText(char *out, int32_t outSize, const char *root, const char *vendor, const char *pkgName);
+static bool idCharValid(char c);
+static bool idPartValid(const char *part, int32_t len);
+static bool inFolder(const char *name, const char *root, const char *vendor, const char *pkgName);
+static bool parseFileLine(PkgManifestT *m, const PkgIniT *ini, char *err, int32_t errSize);
+static bool parsePackageKey(PkgManifestT *m, const PkgIniT *ini, uint32_t *seen, char *err, int32_t errSize);
+static bool parseText(const char *text, int32_t len, bool template, PkgManifestT *m, char *err, int32_t errSize);
+void pkgDvxManifest(PkgManifestT *m);
+bool pkgFileAllowed(const PkgManifestT *m, const char *name);
+void pkgIdPartFrom(const char *text, char *out);
+bool pkgIdSplit(const char *id, char *vendor, char *name);
+bool pkgIdValid(const char *id);
+bool pkgKindFolder(PkgKindE kind, const char *id, char *out, int32_t outSize);
+bool pkgKindIsCode(PkgKindE kind);
+const char *pkgKindName(PkgKindE kind);
+bool pkgKindParse(const char *text, PkgKindE *kind);
+void pkgManifestAddDepend(PkgManifestT *m, const char *id, PkgVersionT min);
+bool pkgManifestAddFile(PkgManifestT *m, const char *name, uint32_t size, const uint8_t sha256[PKG_SHA256_SIZE]);
+bool pkgManifestCheck(const PkgManifestT *m, char *err, int32_t errSize);
+const PkgFileT *pkgManifestFindFile(const PkgManifestT *m, const char *name);
+char *pkgManifestFormat(const PkgManifestT *m, int32_t *outLen);
+void pkgManifestFree(PkgManifestT *m);
+void pkgManifestInit(PkgManifestT *m);
+bool pkgManifestParse(const char *text, int32_t len, PkgManifestT *m, char *err, int32_t errSize);
+bool pkgTemplateRead(const char *path, PkgManifestT *m, char *err, int32_t errSize);
+int32_t pkgVersionCompare(PkgVersionT a, PkgVersionT b);
+void pkgVersionFormat(PkgVersionT v, char *out, int32_t outSize);
+bool pkgVersionParse(const char *text, PkgVersionT *v);
+static void textAdd(TextT *t, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+
+
+// A text value into its field; a value too long fails the parse.
+static void copyText(char *out, int32_t outSize, const char *value, int32_t line, bool *ok, char *err, int32_t errSize) {
+ if ((int32_t)strlen(value) >= outSize) {
+ *ok = fail(err, errSize, "Line %d: the value is too long (at most %d characters).", (int)line, (int)outSize - 1);
+ return;
+ }
+
+ snprintf(out, (size_t)outSize, "%s", value);
+}
+
+
+static bool fail(char *err, int32_t errSize, const char *fmt, ...) {
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(err, (size_t)errSize, fmt, ap);
+ va_end(ap);
+ return false;
+}
+
+
+// /, and / when pkgName: its length, or -1 when it
+// does not fit in outSize.
+static int32_t folderText(char *out, int32_t outSize, const char *root, const char *vendor, const char *pkgName) {
+ int32_t n;
+
+ if (pkgName) {
+ n = snprintf(out, (size_t)outSize, "%s" PATH_SEP_TEXT "%s" PATH_SEP_TEXT "%s", root, vendor, pkgName);
+ } else {
+ n = snprintf(out, (size_t)outSize, "%s" PATH_SEP_TEXT "%s", root, vendor);
+ }
+
+ return (n > 0 && n < outSize) ? n : -1;
+}
+
+
+// A character an id part may hold: a-z, 0-9, or ID_EXTRA_CHAR.
+static bool idCharValid(char c) {
+ return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == ID_EXTRA_CHAR;
+}
+
+
+static bool idPartValid(const char *part, int32_t len) {
+ if (len < 1 || len > PKG_ID_PART_MAX) {
+ return false;
+ }
+
+ for (int32_t i = 0; i < len; i++) {
+ char c = part[i];
+
+ if (!idCharValid(c)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+// True when name lies inside