initial commit

This commit is contained in:
2026-01-28 11:44:15 -06:00
commit e5688ae361
8 changed files with 864 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
Parchment is an app development toolkit for the Cardstock operating system. It handles app uploading and packaging automatically.
The project is currently in an unfinished state. It currently supports packaging apps in the custom .cpkg (Cardstock Package) format, and can automatically detect an attached Cardputer and put it in dev mode. The actual file transfer protocol is currently unimplemented, and Parchment does not currently handle initializing a Cardstock application in the way a development system like NPM can. These features are planned.
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"encoding/binary"
"io/fs"
"os"
"path/filepath"
"strconv"
"log"
"fmt"
)
type fileData struct {
name string
content []byte
}
type cpkg struct {
packageName string
files []fileData
}
func NewCpkg(packageName string) *cpkg {
return &cpkg{
packageName: packageName,
files: make([]fileData, 0),
}
}
func (c *cpkg) AddFile(fsys fs.FS, path string) error {
file, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
c.files = append(c.files, fileData{name: path, content: file})
return nil
}
// cpkg file header format:
// 1 byte: filename length
// n bytes: filename
// 4 bytes: file size (uint32)
func (c *cpkg) createCpkgFileHeader(file fileData) []byte {
headerBytes := make([]byte, 0)
headerBytes = append(headerBytes, byte(len(file.name)))
headerBytes = append(headerBytes, []byte(file.name)...)
// file size (uint32 - 4 bytes)
fileSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(fileSizeBytes, uint32(len(file.content)))
headerBytes = append(headerBytes, fileSizeBytes...)
return headerBytes
}
// cpkg header format:
// 4 bytes: magic number "Cpkg"
// 2 bytes: version
// 4 bytes: file count
func (c *cpkg) createCpkgHeader() []byte {
headerBytes := make([]byte, 0)
headerBytes = append(headerBytes, []byte("Cpkg")...)
// version (uint16 - 2 bytes)
versionBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(versionBytes, 1)
headerBytes = append(headerBytes, versionBytes...)
// file count (uint32 - 4 bytes)
fileCountBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(fileCountBytes, uint32(len(c.files)))
headerBytes = append(headerBytes, fileCountBytes...)
return headerBytes
}
func (c *cpkg) Create() (bool, error) {
file, err := os.Create(c.packageName + ".cpkg")
if err != nil {
return false, err
}
defer file.Close()
// Write main header
header := c.createCpkgHeader()
n, err := file.Write(header)
if err != nil {
return false, fmt.Errorf("failed to write header: %w", err)
}
if n != len(header) {
return false, fmt.Errorf("incomplete header write: wrote %d of %d bytes", n, len(header))
}
// Write each file
for i, fileData := range c.files {
// Write file header
fileHeader := c.createCpkgFileHeader(fileData)
n, err := file.Write(fileHeader)
if err != nil {
return false, fmt.Errorf("failed to write header for file %d (%s): %w", i, fileData.name, err)
}
if n != len(fileHeader) {
return false, fmt.Errorf("incomplete header write for file %d (%s): wrote %d of %d bytes", i, fileData.name, n, len(fileHeader))
}
// Write file content
n, err = file.Write(fileData.content)
if err != nil {
return false, fmt.Errorf("failed to write content for file %d (%s): %w", i, fileData.name, err)
}
if n != len(fileData.content) {
return false, fmt.Errorf("incomplete content write for file %d (%s): wrote %d of %d bytes", i, fileData.name, n, len(fileData.content))
}
log.Printf("Wrote file %d: %s (%d bytes)", i, fileData.name, len(fileData.content))
}
return true, nil
}
func (c *cpkg) DebugPrint() {
log.Println("Package Name:", c.packageName)
idx := 0
for _, file := range c.files {
log.Println("File " + strconv.Itoa(idx) + " header")
log.Println(c.createCpkgFileHeader(file))
idx++
}
}
func (c *cpkg) Unpack(fsys fs.FS) error {
file, err := os.Open(c.packageName + ".cpkg")
if err != nil {
return err
}
defer file.Close()
// Read main header (10 bytes)
header := make([]byte, 10)
_, err = file.Read(header)
if err != nil {
return fmt.Errorf("failed to read header: %w", err)
}
// Verify magic number
magicNumber := string(header[:4])
if magicNumber != "Cpkg" {
return fmt.Errorf("invalid cpkg file: wrong magic number")
}
// Verify version
version := binary.LittleEndian.Uint16(header[4:6])
if version != 1 {
return fmt.Errorf("unsupported cpkg file version: %d", version)
}
// Read file count
fileCount := binary.LittleEndian.Uint32(header[6:10])
log.Printf("Unpacking %d files from %s.cpkg", fileCount, c.packageName)
// Clear existing files array
c.files = make([]fileData, 0, fileCount)
// Read each file sequentially
for i := 0; i < int(fileCount); i++ {
// Read filename length (1 byte)
fileNameLengthBuf := make([]byte, 1)
n, err := file.Read(fileNameLengthBuf)
if err != nil {
return fmt.Errorf("failed to read filename length for file %d: %w", i, err)
}
if n != 1 {
return fmt.Errorf("incomplete read of filename length for file %d: got %d bytes, expected 1", i, n)
}
fileNameLength := fileNameLengthBuf[0]
// Read filename
fileNameBuf := make([]byte, fileNameLength)
n, err = file.Read(fileNameBuf)
if err != nil {
return fmt.Errorf("failed to read filename for file %d: %w", i, err)
}
if n != int(fileNameLength) {
return fmt.Errorf("incomplete read of filename for file %d: got %d bytes, expected %d", i, n, fileNameLength)
}
fileName := string(fileNameBuf)
// Read file size (4 bytes)
fileSizeBuf := make([]byte, 4)
n, err = file.Read(fileSizeBuf)
if err != nil {
return fmt.Errorf("failed to read file size for file %d: %w", i, err)
}
if n != 4 {
return fmt.Errorf("incomplete read of file size for file %d: got %d bytes, expected 4", i, n)
}
fileSize := binary.LittleEndian.Uint32(fileSizeBuf)
// Read file content
fileContent := make([]byte, fileSize)
n, err = file.Read(fileContent)
if err != nil {
return fmt.Errorf("failed to read content for file %d (%s): %w", i, fileName, err)
}
if n != int(fileSize) {
return fmt.Errorf("incomplete read of content for file %d (%s): got %d bytes, expected %d", i, fileName, n, fileSize)
}
log.Printf("Read file %d: %s (%d bytes)", i, fileName, fileSize)
c.files = append(c.files, fileData{name: fileName, content: fileContent})
}
// Write files to filesystem in subdirectory with the package name
err = os.MkdirAll(c.packageName, 0755)
if err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
for _, fileData := range c.files {
outputPath := filepath.Join(c.packageName, fileData.name)
// Create parent directories if they don't exist
parentDir := filepath.Dir(outputPath)
err = os.MkdirAll(parentDir, 0755)
if err != nil {
return fmt.Errorf("failed to create directory for file %s: %w", fileData.name, err)
}
err = os.WriteFile(outputPath, fileData.content, 0644)
if err != nil {
return fmt.Errorf("failed to write file %s: %w", fileData.name, err)
}
log.Printf("Wrote file: %s", outputPath)
}
log.Printf("Successfully unpacked %d files to %s/", len(c.files), c.packageName)
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"fmt"
"time"
"go.bug.st/serial"
"go.bug.st/serial/enumerator"
)
// Device manages the serial connection to the Cardputer.
type Device struct {
PortName string
Port serial.Port
Connected bool
}
// NewDevice creates a new Device instance.
func NewDevice() *Device {
return &Device{}
}
// FindCardputer searches for a connected Cardputer by VID.
func (d *Device) FindCardputer() (string, error) {
ports, err := enumerator.GetDetailedPortsList()
if err != nil {
return "", err
}
for _, port := range ports {
if port.VID == "303a" { // Cardputer VID
d.PortName = port.Name
return port.Name, nil
}
}
return "", fmt.Errorf("Cardputer not found")
}
// Connect opens the serial port and requests dev mode.
func (d *Device) Connect() error {
if d.PortName == "" {
return fmt.Errorf("no port name set")
}
mode := &serial.Mode{
BaudRate: 115200,
}
port, err := serial.Open(d.PortName, mode)
if err != nil {
return fmt.Errorf("failed to open port: %w", err)
}
d.Port = port
d.Connected = true
// Send dev mode request
// 0xAA 0x04 0x00 0x00 0x00 is the command sequence for dev mode
_, err = d.Port.Write([]byte{0xAA, 0x04, 0x00, 0x00, 0x00})
if err != nil {
d.Close()
return fmt.Errorf("failed to send dev mode request: %w", err)
}
// Allow some time for the device to respond or settle if needed
time.Sleep(100 * time.Millisecond)
return nil
}
// Close closes the serial port.
func (d *Device) Close() error {
if d.Port != nil {
err := d.Port.Close()
d.Port = nil
d.Connected = false
return err
}
return nil
}
// Write sends data to the device.
func (d *Device) Write(data []byte) (int, error) {
if !d.Connected || d.Port == nil {
return 0, fmt.Errorf("device not connected")
}
return d.Port.Write(data)
}
+29
View File
@@ -0,0 +1,29 @@
module github.com/wisplite/parchment
go 1.25.6
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.bug.st/serial v1.6.4 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"fmt"
"log"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
// Setup logging to file
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
// Initialize Device manager
device := NewDevice()
// Initial search for device
// We do this synchronously at startup for convenience,
// but the UI could also handle re-scanning.
portName, err := device.FindCardputer()
// Initialize UI Model
m := InitialModel(device)
if err == nil {
m.Status = StatusFound
log.Printf("Found Cardputer at %s", portName)
} else {
log.Printf("Cardputer not found at startup: %v", err)
// We start in Searching state, user might plug it in later if we implemented auto-scan,
// but for now it just shows searching or error.
// Since our basic implementation does a one-time scan at start,
// if it fails, we might want to let the user retry or just show the state.
// The current InitialModel defaults to StatusSearching.
// To match original behavior (sort of), if we don't find it, we stay in Searching/Error.
// Actually, let's just leave it as StatusSearching or set Error if we want to be explicit.
}
// Create and run the Bubble Tea program
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatal("Error running program:", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"io/fs"
"encoding/json"
"strings"
"log"
)
type PackageJSON struct {
Pkg string `json:"pkg"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
Icon string `json:"icon"`
}
/*
Packager is a class that helps create cpkg files and handle the upload process.
*/
func loadPackageJSON(fsys fs.FS) (*PackageJSON, error) {
packageJSON, err := fs.ReadFile(fsys, "package.json")
if err != nil {
return nil, err
}
pkgData := &PackageJSON{}
err = json.Unmarshal(packageJSON, pkgData)
if err != nil {
return nil, err
}
return pkgData, nil
}
func createCpkg(fsys fs.FS, pkgData *PackageJSON) (*cpkg, error) {
pkg := NewCpkg(pkgData.Pkg)
// Walk through all directories recursively
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories themselves (we only want files)
if d.IsDir() {
return nil
}
// Skip certain files
fileName := d.Name()
if fileName == "debug.log" || strings.HasSuffix(fileName, ".cpkg") {
log.Println("Skipping file:", path)
return nil
}
// Add file with full relative path
log.Println("Adding file:", path)
err = pkg.AddFile(fsys, path)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
success, err := pkg.Create()
if err != nil {
return nil, err
}
if !success {
return nil, err
}
return pkg, nil
}
+328
View File
@@ -0,0 +1,328 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// AppStatus represents the current state of the application.
type AppStatus int
const (
StatusSearching AppStatus = iota
StatusFound
StatusConnecting
StatusConnected
StatusError
StatusUploading
StatusUploaded
StatusPromptUnpack
StatusUnpacking
StatusUnpacked
)
// UIModel is the Bubble Tea model for the application.
type UIModel struct {
Device *Device
Status AppStatus
ErrorMsg string
TextInput textinput.Model
Width int
Height int
}
// Custom messages for Bubble Tea commands
type connectMsg struct {
err error
}
type uploadMsg struct {
err error
}
type unpackMsg struct {
err error
}
// InitialModel creates the initial state of the model.
func InitialModel(device *Device) UIModel {
ti := textinput.New()
ti.Placeholder = "package-name"
ti.CharLimit = 256
ti.Width = 40
return UIModel{
Device: device,
Status: StatusSearching,
TextInput: ti,
}
}
// Init initializes the model.
func (m UIModel) Init() tea.Cmd {
return textinput.Blink
}
// Update handles messages and updates the model.
func (m UIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.Width = msg.Width
m.Height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
// Ensure device is closed on exit
if m.Device != nil {
m.Device.Close()
}
return m, tea.Quit
}
}
// Handle input based on current status
if m.Status == StatusPromptUnpack {
return m.updatePromptUnpack(msg)
}
return m.updateMain(msg)
}
func (m UIModel) updatePromptUnpack(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.Device.Connected {
m.Status = StatusConnected
} else {
m.Status = StatusFound
}
m.TextInput.Reset()
return m, nil
case "enter":
filename := m.TextInput.Value()
if filename != "" {
m.Status = StatusUnpacking
m.TextInput.Reset()
return m, debugUnpackCmd(filename)
}
}
}
var cmd tea.Cmd
m.TextInput, cmd = m.TextInput.Update(msg)
return m, cmd
}
func (m UIModel) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "c", "enter":
if m.Status == StatusFound {
m.Status = StatusConnecting
return m, connectCmd(m.Device)
}
case "u":
if m.Status == StatusConnected {
m.Status = StatusUploading
return m, uploadCmd(m.Device)
}
case "d":
if m.Status == StatusFound || m.Status == StatusConnected || m.Status == StatusUploaded || m.Status == StatusUnpacked {
m.Status = StatusPromptUnpack
m.TextInput.Focus()
return m, textinput.Blink
}
}
case connectMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusConnected
return m, nil
case uploadMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusUploaded
return m, nil
case unpackMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusUnpacked
return m, nil
}
return m, nil
}
// View renders the application UI.
func (m UIModel) View() string {
var s strings.Builder
// Styles
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1).
MarginBottom(1)
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true)
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true)
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true)
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#A0A0A0"))
// Header
s.WriteString(titleStyle.Render("Parchment - Cardstock Dev Server"))
s.WriteString("\n")
// Content
content := strings.Builder{}
switch m.Status {
case StatusSearching:
content.WriteString(statusStyle.Render("⏳ Searching for Cardputer..."))
case StatusFound:
content.WriteString(successStyle.Render("✓ Cardputer found"))
content.WriteString(fmt.Sprintf(" at %s\n\n", m.Device.PortName))
content.WriteString(keyStyle.Render("[c] / [enter]") + descStyle.Render(" Connect and enter dev mode\n"))
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack (local)\n"))
case StatusConnecting:
content.WriteString(statusStyle.Render("⏳ Connecting and requesting dev mode..."))
case StatusConnected:
content.WriteString(successStyle.Render("✓ Connected to Cardputer!"))
content.WriteString(fmt.Sprintf("\n\nPort: %s\n", m.Device.PortName))
content.WriteString("Device is in developer mode.\n\n")
content.WriteString(keyStyle.Render("[u]") + descStyle.Render(" Upload package from current directory\n"))
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack (local)\n"))
case StatusUploading:
content.WriteString(statusStyle.Render("⏳ Uploading package to device..."))
case StatusUploaded:
content.WriteString(successStyle.Render("✓ Package uploaded successfully!"))
content.WriteString(fmt.Sprintf("\n\nPort: %s\n", m.Device.PortName))
content.WriteString("\n")
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack\n"))
case StatusPromptUnpack:
content.WriteString(statusStyle.Render("Debug Unpack"))
content.WriteString("\n\nEnter the filename to unpack (without .cpkg extension):\n\n")
content.WriteString(m.TextInput.View())
content.WriteString("\n\n")
content.WriteString(keyStyle.Render("[enter]") + descStyle.Render(" Unpack\n"))
content.WriteString(keyStyle.Render("[esc]") + descStyle.Render(" Cancel\n"))
case StatusUnpacking:
content.WriteString(statusStyle.Render("⏳ Debug packing and unpacking..."))
case StatusUnpacked:
content.WriteString(successStyle.Render("✓ Debug pack/unpack completed!"))
content.WriteString("\n\nCheck debug.log for details.\n")
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Run again\n"))
case StatusError:
content.WriteString(errorStyle.Render("✗ Error: "))
content.WriteString(m.ErrorMsg)
content.WriteString("\n\n")
if m.Device.Connected {
content.WriteString(keyStyle.Render("[u]") + descStyle.Render(" Retry upload\n"))
} else {
content.WriteString(keyStyle.Render("[c]") + descStyle.Render(" Retry connection\n"))
}
}
// Render content with some margin
s.WriteString(lipgloss.NewStyle().Margin(1, 0, 1, 0).Render(content.String()))
// Footer
s.WriteString("\n")
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render("Press [q] or [ctrl+c] to quit")
s.WriteString(footer)
return lipgloss.NewStyle().Padding(1, 2).Render(s.String())
}
// Commands
func connectCmd(d *Device) tea.Cmd {
return func() tea.Msg {
if err := d.Connect(); err != nil {
return connectMsg{err: err}
}
return connectMsg{err: nil}
}
}
func uploadCmd(d *Device) tea.Cmd {
return func() tea.Msg {
// Mock upload for now or implement actual upload logic using Device
// We'll use the existing logic from main.go adapted here
fsys := os.DirFS(".")
pkgData, err := loadPackageJSON(fsys)
if err != nil {
return uploadMsg{err: err}
}
if pkgData == nil {
return uploadMsg{err: fmt.Errorf("package.json not found")}
}
log.Println("Loading package data:", pkgData)
pkg, err := createCpkg(fsys, pkgData)
if err != nil {
return uploadMsg{err: err}
}
pkg.DebugPrint()
// TODO: Actually send the package over serial using d.Write()
// For now we just simulate success as per original code
return uploadMsg{err: nil}
}
}
func debugUnpackCmd(filename string) tea.Cmd {
return func() tea.Msg {
fsys := os.DirFS(".")
packageName := strings.TrimSuffix(filename, ".cpkg")
log.Printf("Attempting to unpack: %s.cpkg", packageName)
if _, err := os.Stat(packageName + ".cpkg"); os.IsNotExist(err) {
return unpackMsg{err: fmt.Errorf("file not found: %s.cpkg", packageName)}
}
unpackPkg := NewCpkg(packageName)
err := unpackPkg.Unpack(fsys)
if err != nil {
return unpackMsg{err: fmt.Errorf("failed to unpack: %w", err)}
}
log.Println("Debug unpack completed successfully")
return unpackMsg{err: nil}
}
}