~aleteoryx/tclircc

d356cceed96d35c3966d4cce0662c62078859b23 — Aleteoryx 11 days ago 4bc1948
got too big for my britches. let's try this again!
27 files changed, 9 insertions(+), 1054 deletions(-)

M bin/docgen.tcl
D doc/doctools/gen/cap.man
D doc/doctools/gen/db/main.man
D doc/doctools/gen/irc/main.man
D doc/doctools/gen/main.man
D doc/doctools/gen/plugins.man
D doc/doctools/gen/router.man
D doc/doctools/gen/spdx/lib.man
D doc/doctools/gen/ui/main.man
D doc/md/cap.md
D doc/md/db/main.md
D doc/md/irc/main.md
D doc/md/main.md
D doc/md/plugins.md
D doc/md/router.md
D doc/md/spdx/lib.md
D doc/md/ui/main.md
M src/cap.tcl
D src/db/main.tcl
D src/irc/main.tcl
M src/main.tcl
D src/plugins.tcl
D src/router.tcl
D src/spdx/dump.tcldict.gz
D src/spdx/lib.tcl
D src/ui/main.tcl
D testplugin/manifest.tcl
M bin/docgen.tcl => bin/docgen.tcl +9 -0
@@ 25,12 25,21 @@ foreach dir [list \
cd [file join $basedir src]

set files [glob -nocomplain {*}$files]
set okfiles {}

foreach file $files {
  set opath [file join $basedir doc doctools gen $file]
  file mkdir [file dirname $opath]

  set fd [open $file r]
  if [regexp -line {^[ \t]*#\*\*\*} [read $fd]] {
    lappend okfiles $file
  }
  close $fd
}

set files $okfiles

file mkdir [file join $basedir doc doctools gen]
exec -- [file join $basedir bin docextract.tcl] -o [file join $basedir doc doctools gen] -all {*}$files


D doc/doctools/gen/cap.man => doc/doctools/gen/cap.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin cap tclircc 0.0.1]
[titledesc {Library cap.tcl}]
[description]
[manpage_end]


D doc/doctools/gen/db/main.man => doc/doctools/gen/db/main.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin db_thread tclircc 0.0.1]
[titledesc {Thread tclircc::db}]
[description]
[manpage_end]


D doc/doctools/gen/irc/main.man => doc/doctools/gen/irc/main.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin irc_thread tclircc 0.0.1]
[titledesc {Thread tclircc::irc}]
[description]
[manpage_end]


D doc/doctools/gen/main.man => doc/doctools/gen/main.man +0 -17
@@ 1,17 0,0 @@
[manpage_begin main_thread tclircc 0.0.1]
[titledesc {Thread main}]
[description]
[para]
This is the application entrypoint. It does the following.
[list_begin itemized]
[item]
brings up the routing system
[item]
brings up the database
[item]
loads the other core threads
[item]
opens a window
[list_end]
[manpage_end]


D doc/doctools/gen/plugins.man => doc/doctools/gen/plugins.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin plugins tclircc 0.0.1]
[titledesc {Component plugins.tcl}]
[description]
[manpage_end]


D doc/doctools/gen/router.man => doc/doctools/gen/router.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin router tclircc 0.0.1]
[titledesc {Component router.tcl}]
[description]
[manpage_end]


D doc/doctools/gen/spdx/lib.man => doc/doctools/gen/spdx/lib.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin spdx tclircc 0.0.1]
[titledesc {Library spdx/lib.tcl}]
[description]
[manpage_end]


D doc/doctools/gen/ui/main.man => doc/doctools/gen/ui/main.man +0 -5
@@ 1,5 0,0 @@
[manpage_begin ui_thread tclircc 0.0.1]
[titledesc {Thread tclircc::ui}]
[description]
[manpage_end]


D doc/md/cap.md => doc/md/cap.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (cap \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (cap\(tclircc\) 0\.0\.1  "")

# NAME

cap \- Library cap\.tcl

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/db/main.md => doc/md/db/main.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (db\_thread \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (db\_thread\(tclircc\) 0\.0\.1  "")

# NAME

db\_thread \- Thread tclircc::db

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/irc/main.md => doc/md/irc/main.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (irc\_thread \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (irc\_thread\(tclircc\) 0\.0\.1  "")

# NAME

irc\_thread \- Thread tclircc::irc

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/main.md => doc/md/main.md +0 -29
@@ 1,29 0,0 @@
---
toc: false
---

[//000000001]: # (main\_thread \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (main\_thread\(tclircc\) 0\.0\.1  "")

# NAME

main\_thread \- Thread main

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

This is the application entrypoint\. It does the following\.

  - brings up the routing system

  - brings up the database

  - loads the other core threads

  - opens a window

D doc/md/plugins.md => doc/md/plugins.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (plugins \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (plugins\(tclircc\) 0\.0\.1  "")

# NAME

plugins \- Component plugins\.tcl

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/router.md => doc/md/router.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (router \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (router\(tclircc\) 0\.0\.1  "")

# NAME

router \- Component router\.tcl

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/spdx/lib.md => doc/md/spdx/lib.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (spdx \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (spdx\(tclircc\) 0\.0\.1  "")

# NAME

spdx \- Library spdx/lib\.tcl

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

D doc/md/ui/main.md => doc/md/ui/main.md +0 -19
@@ 1,19 0,0 @@
---
toc: false
---

[//000000001]: # (ui\_thread \- )
[//000000002]: # (Generated from file 'doctools' by tcllib/doctools with format 'markdown')
[//000000003]: # (ui\_thread\(tclircc\) 0\.0\.1  "")

# NAME

ui\_thread \- Thread tclircc::ui

# <a name='toc'></a>Table Of Contents

  - [Table Of Contents](#toc)

  - [Description](#section1)

# <a name='description'></a>DESCRIPTION

M src/cap.tcl => src/cap.tcl +0 -9
@@ 1,9 1,3 @@
#***
# [manpage_begin cap tclircc 0.0.1]
# [titledesc {Library cap.tcl}]
# [description]


# cap.status:
# - sent (we have sent CAP LS 302, no terminal reply)
# - ack-wait (we've sent off the first REQ, wait for an ACK to update state and send END. will only transition when cap.req-inflight == {})


@@ 320,6 314,3 @@ namespace eval ::cap {
    irc::msg send $chan CAP LS 302
  }
}

#***
# [manpage_end]

D src/db/main.tcl => src/db/main.tcl +0 -46
@@ 1,46 0,0 @@
#***
# [manpage_begin db_thread tclircc 0.0.1]
# [titledesc {Thread tclircc::db}]
# [description]

package require sqlite3
package require logger

set log [logger::init tclircc::db]

switch -- [set tcl_platform(platform)] {
  "windows" {
    set data_dir [file join [set env(APPDATA)] tclircc]
  }
  "unix" {
    if [info exists env(XDG_CONFIG_HOME)] {
      set data_dir [file join [set env(XDG_CONFIG_HOME)] tclircc]
    } elseif [info exists env(HOME)] {
      set data_dir [file join [set env(HOME)] .config tclircc]
    } else {
      return -code error {Missing $HOME or $XDG_CONFIG_HOME, can't store config!}
    }
  }
  default {
    return -code error {Unknown platform, can't store config.}
  }
}

file mkdir $data_dir

proc path_to_core {} {
  global data_dir
  return [file join $data_dir core.db]
}

proc backup {} {
  global log data_dir
  set backup_dir [string cat $data_dir _backup_ [clock microseconds]]
  ${log}::info "backing up data directory to $backup_dir..."
  file copy $data_dir $backup_dir
  ${log}::info "backup to $backup_dir complete!"
  return $backup_dir
}

#***
# [manpage_end]

D src/irc/main.tcl => src/irc/main.tcl +0 -12
@@ 1,12 0,0 @@
#***
# [manpage_begin irc_thread tclircc 0.0.1]
# [titledesc {Thread tclircc::irc}]
# [description]

package require logger

source [file join $path irc.tcl]
source [file join $path cap.tcl]

#***
# [manpage_end]

M src/main.tcl => src/main.tcl +0 -92
@@ 1,94 1,2 @@
#!/bin/env tclsh

#***
# [manpage_begin main_thread tclircc 0.0.1]
# [titledesc {Thread main}]
# [description]
# [para]
# This is the application entrypoint. It does the following.
# [list_begin itemized]



set path [file dirname [dict get [info frame 0] file]]
set version v0.0.1
proc src {file} {
  global path
  uplevel [list source [file join $path $file]]
}

package require Thread
thread::id; # avoid use-after-free
package require logger
set log [logger::init tclircc::main]
package require sqlite3

${log}::info "tclircc $version <https://amehut.dev/~aleteoryx/tclircc>"
${log}::info "running from $path"

#***
# [item]
# brings up the routing system
src router.tcl

proc on_routes_update {} {
  puts "routes updated!"
  r::debug
}

proc start_thread {name} {
  global path
  global version
  global log

  ${log}::debug "starting $name thread..."

  set thread [thread::create -preserved]
  router::manage $thread tclircc::$name
  thread::send $thread [list variable path $path version $version]

  if {[thread::send $thread \
        [list source [file join $path $name main.tcl] ] result] == 1} {
    ${log}::critical "couldn't start $name thread: $result"
    exit -1
  }

  ${log}::debug "started $name thread."
}

#***
# [item]
# brings up the database
start_thread db

thread::send [r::ns tclircc::db] {path_to_core} core_db_path
sqlite3 core_db $core_db_path -create true -fullmutex true
src migrate_core.tcl

src plugins.tcl

#***
# [item]
# loads the other core threads
start_thread irc
start_thread ui

plugins::load [file join $path .. testplugin]

#***
# [item]
# opens a window
${log}::debug "opening initial window..."
r::exec tclircc::ui {
  mk_toplevel name
  expr {$name}
} {
  ${log}::debug "initial window opened: $result"
}

${log}::info "entering event loop"
vwait nil

#***
# [list_end]
# [manpage_end]

D src/plugins.tcl => src/plugins.tcl +0 -396
@@ 1,396 0,0 @@
#***
# [manpage_begin plugins tclircc 0.0.1]
# [titledesc {Component plugins.tcl}]
# [description]

package require sha256
package require html
package require htmlparse
if {[info procs ::spdx::*] == {}} {
  global path
  source [file join $path "spdx/lib.tcl"]
}

# plugin manifest format:
# tcl script that must set the following variables at a minimum:
# - name: string
# - slug: string
#   must match /^[-_a-zA-Z0-9]+$/
# - namespace: string
#   must match /^[-_a-zA-Z0-9]+$/
# - version: string[]
#   ordered like {1} > {0}, {0 1} < {1 0}. {1 1} > {1}
# may set:
# - description: string
# - author: string[]
#   should contain a list of names with optional website or email, e.g.
#   {{Aleteoryx <https://aleteoryx.me>}
#    {Alice P Hacker <aph@example.com>}}
# - license: string
#   may begin with spdx: to indicate an spdx id or file: to indicate
#   a file path relative to the plugin root. .. and root references are
#   stripped. if neither prefix applies, assumed to be literal license
#   text.


namespace eval plugins {
  variable plugins
  variable log [logger::init tclircc::plugins]

  # generate the script to exfiltrate data from the manifest
  variable mf_required {
    name 1
    namespace {[regexp {^[_a-zA-Z0-9-]+$} $val]}
    slug {[regexp {^[_a-zA-Z0-9-]+$} $val]}
    version {$val != ""} }
  variable mf_optional {
    description 1
    authors 1
    license 1 }
  variable mf_procs {
    
  }
  variable manifest_reader {
    set manifest_dict {}
  }
  foreach {key check} $mf_required {
    set chunk {
      if {[info exists %key]} {
        set val [set %key]
        if %check {
          dict set manifest_dict %key [set %key]
        } else { return -code error "invalid value for %key: $val" }
      } else { return -code error "missing key in manifest: %key" }
    }

    regsub -all "%key" $chunk [list $key] chunk
    regsub -all "%check" $chunk [list $check] chunk

    append manifest_reader $chunk
  }
  foreach {key check} $mf_optional {
    set chunk {
      if {[info exists %key]} {
        set val [set %key]
        if %check {
          dict set manifest_dict %key [set %key]
        }
      }
    }

    regsub -all "%key" $chunk [list $key] chunk
    regsub -all "%check" $chunk [list $check] chunk

    append manifest_reader $chunk
  }
  foreach {key _} $mf_procs {
    set chunk {
      if {%key in [info procs %key]} {
        dict set manifest_dict procs %key [list [info args %key] [info body %key]]
      }
    }

    regsub -all "%key" $chunk [list $key] chunk

    append manifest_reader $chunk
  }
  append manifest_reader { set manifest_dict }
  # generation done

  proc vercmp {v1 v2} {
    # normalize the lists, check for equality
    if {[list {*}$v1] == [list {*}$v2]} { return 0 }
    # compare every element
    for {set n 0} {$n < min([llength $v1], [llength $v2])} {incr n} {
      if [set cmp [string compare [lindex $v1 $n] [lindex $v2 $n]]] {
        return $cmp
      }
    }
    # they can't be equal, so the longer one must be the newer one
    if {[llength $v1] > [llength $v2]} { return 1 }
    return -1
  }
  
  # TODO: parse markdown to html, parse plaintext <links> to html
  proc format_license {license dir} {
    switch -glob -- $license {
      spdx:* {
        set license [string range $license 5 end]
        if ![spdx::exists $license] {
          return "<b>License:</b> <i>Unknown (invalid SPDX code \"[html::html_entities $license\"])</i>"
        }
        set license_name [spdx::get_name $license]
        set license_uri [spdx::get_spdx_uri $license]
        set license [spdx::get_text $license]
      }
      file:* {
        set license [string range $license 5 end]
        set license_name [file tail [file rootname $license]]
        set license_path [file join $dir [string trim [file normalize /$license] /]]
        set license_uri "file://$license_path"
        if [catch {
          set fd [open $license_path r]
          set license [read $fd]
          close $fd
          unset fd
        }] {
          set license_path [html::html_entities $license_path]
          return "<b>License:</b> <i>Unreadable (couldn't load file \"<a href=\"$license_uri\">$license_path</a>\")</i>"
        }
      }
      * {
        set license_name custom
        set license_uri {}
      }
    }

    set license [html::html_entities $license]
    regsub -all {&lt;((?:https?|ftps?|gopher|gemini|spartan|file)://(?:(?!&gt;).)+)&gt;} $license {<a href="\1">\1</a>} license
    regsub -all {&lt;([^@]+@(?:(?!&gt;)[^@])+?)&gt;} $license {<a href="mailto:\1">\1</a>} license
    set license_name [html::html_entities $license_name]
    set license_uri [html::html_entities $license_uri]
    return "<b>License:</b> <i><a href=\"$license_uri\">$license_name</a></i>\n\n$license"
  }

  proc enroll_interp {tid iid} {}

  proc load {dir {skip_prompt 0}} {
    variable log
    variable manifest_reader

    set dir [file normalize $dir]
    ${log}::info "attempting to load plugin from \"$dir\"..."

    set mf_path [file join $dir manifest.tcl]
    if {!([file isfile $mf_path] && [file readable $mf_path])} {
      ${log}::error "couldn't load plugin: manifest \"$mf_path\" not present or not readable!"
      return 0
    }
    set mf_fd [open $mf_path]
    set manifest [read $mf_fd]
    close $mf_fd

    interp create -safe mf_exec
    # prevent escapes
    interp hide mf_exec package
    interp hide mf_exec interp
    # prevent hanging the thread
    interp hide mf_exec vwait
    interp hide mf_exec after
    interp hide mf_exec update
    interp hide mf_exec gets
    interp hide mf_exec read

    # this is more than enough for any reasonable manifest
    interp limit mf_exec time -seconds [expr {[clock seconds] + 2}]
    interp limit mf_exec command -value 10000

    # untrusted code time
    ${log}::debug "evaluating manifest..."
    set untrusted_result [catch {
      set mf_result [catch { interp eval mf_exec $manifest } result opts]
      if {$mf_result != 0} {
        ${log}::error "couldn't load plugin: manifest return code $mf_result: $result"
        return 0
      }

      set mf_valid [catch { interp eval mf_exec $manifest_reader } result opts]
      if {$mf_valid != 0} {
        ${log}::error "couldn't validate plugin: $result"
        return 0
      }

      # hooray, we have the manifest!
      set manifest $result
      interp delete mf_exec
    } result opts]
    switch -- $untrusted_result {
      0 {  } 2 { return -code 2 result }
      1 {
        ${log}::error "couldn't load plugin: $result"
        return 0
      }
      default {
        ${log}::alert "unexpected return code from plugin handling: $untrusted_result"
        ${log}::alert "return options: $opts"
        ${log}::alert "THIS MAY INDICATE A PLUGIN SANDBOX COMPROMISE!"
        return 0
      }
    }

    set pl_name [dict get $manifest name]
    set pl_namespace [dict get $manifest namespace]
    set pl_slug [dict get $manifest slug]
    set pl_version [dict get $manifest version]

    set pl_qslug "${pl_namespace}::${pl_slug}"
    set pl_verstr "v[join $pl_version .]"

    # qualified slug
    dict set manifest qslug $pl_qslug
    dict set manifest verstr $pl_verstr

    ${log}::debug "manifest loaded: $pl_name (${pl_qslug}) $pl_verstr"

    set stored [core_db eval {
      SELECT manifest_hash, version, trusted_until FROM plugins
        WHERE slug = $pl_slug
          AND namespace = $pl_namespace}]

    dict set manifest license [format_license [dict get $manifest license] $dir]

    if ![llength $stored] {
      tailcall int_load_new $skip_prompt $manifest $dir
    } else {
      lassign $stored st_mf_hash st_version st_trusted_till
      switch -- [vercmp $pl_version $st_version] {
        0 { tailcall int_load_existing $manifest $dir $mf_path $st_mf_hash $st_trusted_till }
        1 { tailcall int_load_updated $skip_prompt $manifest $dir $st_version $st_trusted_till }
       -1 { tailcall int_load_old $manifest $dir $st_version }
      }
    }
  }

  proc int_load_new {skip_prompt manifest dir} {
    variable log
    ${log}::info "installing new plugin [dict get $manifest qslug]"

    if {!$skip_prompt} {
      set manifest [list $manifest]
      set dir [list $dir]
      r::exec tclircc::ui {
        set manifest %manifest
        prompt -type yesno \
          -title "Plugin Installation" \
          -icon "question" \
          -message "Do you want to install \"[dict get $manifest name]\"?" \
          -detail [string cat \
            "[dict get $manifest qslug] [dict get $manifest verstr]" \
            "\nBy [join [dict get $manifest authors] ,]." \
            "\n\n[dict get $manifest license]" ]
      } {
        set manifest %manifest
        switch -- $result {
          yes {
            ::plugins::install $manifest %dir
          }
          no {
            set log %log
            ${log}::info "user cancelled installation of plugin [dict get $manifest qslug]"
          }
        }
      }
    } else {
      install $manifest $dir
    }
  }
  proc int_load_existing {manifest dir mf_path mf_hash trusted_till} {
    variable log
    ${log}::debug "loading existing plugin [dict get $manifest qslug]"

    if {$trusted_till >= [clock seconds]} {
      install $manifest $dir
    } elseif {[sha2::sha256 -file $mf_path] == $mf_hash} {
      install $manifest $dir
    } else {
      ${log}::warn "plugin may have been tampered with!"

      set manifest [list $manifest]
      set dir [list $dir]
      r::exec tclircc::ui {
        set manifest %manifest
        prompt -type yesno \
          -title "Plugin Tampering Detected!" \
          -icon "warning" \
          -message "\"[dict get $manifest name]\" ([dict get $manifest qslug]) has been edited since last load!" \
          -detail [string cat \
            "This may indicate a security compromise, do you want to continue loading it?" \
            " (If you are doing plugin development, set the \"Trusted Until\" option!)"]
      } {
        set manifest %manifest
        set log %log
        switch -- $result {
          yes {
            ${log}::warn "loading [dict get $manifest qslug] despite potential tampering!"
            ::plugins::install $manifest %dir
          }
          no {
            ${log}::info "not loading [dict get $manifest qslug] due to tampering"
          }
        }
      }
    }
  }
  proc int_load_updated {skip_prompt manifest dir version trusted_till} {
    variable log
    ${log}::info "updating plugin [dict get $manifest qslug] (v[join $version .] -> [dict get $manifest verstr])"

    if {!$skip_prompt && [clock seconds] > $trusted_till} {
      set manifest [list $manifest]
      set dir [list $dir]
      r::exec tclircc::ui {
        set manifest %manifest
        prompt -type yesno \
          -title "Plugin Update" \
          -icon "question" \
          -message "Do you want to update \"[dict get $manifest name]\"?" \
          -detail [string cat \
            "[dict get $manifest qslug] [dict get $manifest verstr]" \
            "\nBy [join [dict get $manifest authors] ,]." \
            "\n\n[dict get $manifest license]" ]
      } {
        set manifest %manifest
        switch -- $result {
          yes {
            ::plugins::install $manifest %dir
          }
          no {
            set log %log
            ${log}::info "user cancelled update of plugin [dict get $manifest qslug]"
          }
        }
      }
    } else {
      install $manifest $dir
    }
  }
  proc int_load_old {manifest dir version} {
    variable log
    ${log}::warn "attempting to load older version of [dict get $manifest qslug] (v[join $version .] -> [dict get $manifest verstr])"

    set verstr [list "v[join $version .]"]
    set manifest [list $manifest]
    set dir [list $dir]
    r::exec tclircc::ui {
      set manifest %manifest
      tk_messageBox -type yesno \
        -title "Plugin Downgrade!" \
        -icon "warning" \
        -message [string cat \
          "Are you sure you want to downgrade \"[dict get $manifest name]" \
          "\" ([dict get $manifest qslug])? A database backup will be made." ] \
        -detail [string cat \
          "Last saw " %verstr ", attempting to load [dict get $manifest verstr]."]
    } {
      set manifest %manifest
      set log %log
      switch -- $result {
        yes {
          ${log}::warn "downgrading [dict get $manifest qslug]! making a backup!"
          r::exec tclircc::db backup {
            ::plugins::install $manifest %dir
          }
        }
        no {
          ${log}::info "not downgrading [dict get $manifest qslug]"
        }
      }
    }
  }
  proc install {manifest dir} {

  }
}

#***
# [manpage_end]

D src/router.tcl => src/router.tcl +0 -133
@@ 1,133 0,0 @@
#***
# [manpage_begin router tclircc 0.0.1]
# [titledesc {Component router.tcl}]
# [description]

namespace eval router {
  variable routes [list main [thread::id]]
  variable log [logger::init tclircc::router]

  variable r_ns_script {
    namespace eval ::r {
      variable ns
      variable self
      proc update {} {
        variable ns
        thread::send -head [set ns(main)] ::router::update
      }
      proc ns {name} {
        variable ns
        set ns($name)
      }
      proc debug {} {
        variable log
        parray r::ns
      }
      proc util_regsub_prep {data} {
        set first -1
        while {[set first [string first \\ $data $first+1]] != -1} {
          if {[string index $data $first+1] ni {0 1 2 3 4 5 6 7 8 9}} {
            continue
          }
          set data [string replace $data $first $first \\\\]
          incr first
        }
        set first -1
        while {[set first [string first & $data $first+1]] != -1} {
          set data [string replace $data $first $first {\&}]
          incr first
        }
        
        return $data
      }
      proc util_pctsub {script} {
        set new_script {}
        set idx 0
        while {[set subst_start [string first "%" $script $idx]] != -1} {
          append new_script [string range $script $idx $subst_start-1]
          if {[string index $script $subst_start+1] == "%"} {
            append new_script %
            set idx [expr {$subst_start + 2}]
            continue
          }
          regexp -start [expr {$subst_start + 1}] -indices {[^A-Za-z0-9_:]} $script match
          if {[llength $match]} {
            lassign $match subst_end
            set subst_end $subst_end-1
          } else {
            set subst_end [string length $script]
          }
          upvar [string range $script $subst_start+1 $subst_end] subst_data
          append new_script $subst_data
          set idx [expr $subst_end + 1]
        }
        append new_script [string range $script $idx end]

        return $new_script
      }
      proc exec {name script {followup {}}} {
        variable ns
        variable self

        set script [uplevel [list ::r::util_pctsub $script]]
        set followup [uplevel [list ::r::util_pctsub $followup]]

        if [string length $followup] {
          set realscript {
            set result [eval %script]
            set script [concat [list set result $result] ";" %followup]
            thread::send -async [r::ns %self] $script
          }
          regsub %self $realscript [util_regsub_prep [list $self]] realscript
          regsub %followup $realscript [util_regsub_prep [list $followup]] realscript
          regsub %script $realscript [util_regsub_prep [list $script]] realscript
          set script $realscript
        }

        thread::send -async [set ns($name)] $script
      }
    }
  }
  eval $r_ns_script
  set ::r::self "main"

  proc manage {tid name} {
    variable routes
    variable log
    variable r_ns_script
    dict set routes $name $tid
    ${log}::debug "managing thread $tid as \"$name\""

    thread::send -head $tid [concat $r_ns_script ";" [list set ::r::self $name]]

    ::router::update
  }
  proc unmanage {name} {
    variable routes
    variable log
    dict unset routes $name
    ${log}::debug "unmanaging thread $tid (\"$name\")"
    ::router::update
  }
  proc update {} {
    variable routes
    dict for {name tid} $routes {
      if ![thread::exists $tid] {dict unset routes $name}
    }
    dict for {name tid} $routes {
      set payload {array unset r::ns; }
      dict for {n t} $routes {
        append payload [list array set r::ns [list $n $t]] ";\n"
      }
      append payload {
        if {"on_routes_update" in [info procs on_routes_update]} \
          on_routes_update
      }
      thread::send -head $tid $payload
    }
    ::update
  }
}

#***
# [manpage_end]

D src/spdx/dump.tcldict.gz => src/spdx/dump.tcldict.gz +0 -0
D src/spdx/lib.tcl => src/spdx/lib.tcl +0 -66
@@ 1,66 0,0 @@
#***
# [manpage_begin spdx tclircc 0.0.1]
# [titledesc {Library spdx/lib.tcl}]
# [description]

namespace eval spdx {
  variable log [logger::init spdx]

  variable db

  proc load {} {
    variable log
    variable db

    if {[array exists db]} { return }

    ${log}::debug "loading database..."

    set dump_path [dict get [info frame [info frame]] file]
    set dump_path [file dirname $dump_path]
    set dump_path [file join $dump_path dump.tcldict.gz]

    set dump [open $dump_path]
    zlib push gunzip $dump
    set data [read $dump]
    close $dump
    unset dump
    unset dump_path

    foreach {name info} $data {
      set db($name) $info
    }
    unset data

    ${log}::debug "database loaded with [array size db] licenses!"
  }

  proc exists {id} {
    variable db
    ::spdx::load
    expr {$id in [array names db -exact $id]}
  }
  proc get_name {id} {
    variable db
    ::spdx::load
    dict get [set db($id)] name
  }
  proc get_text {id} {
    variable db
    ::spdx::load
    dict get [set db($id)] text
  }
  proc get_spdx_uri {id} {
    variable db
    ::spdx::load
    dict get [set db($id)] spdx_uri
  }
  proc get_see_also {id} {
    variable db
    ::spdx::load
    dict get [set db($id)] see_also
  }
}

#***
# [manpage_end]

D src/ui/main.tcl => src/ui/main.tcl +0 -79
@@ 1,79 0,0 @@
#***
# [manpage_begin ui_thread tclircc 0.0.1]
# [titledesc {Thread tclircc::ui}]
# [description]

variable toplevel_count 0
variable toplevel_tabs
variable toplevel_classes

package require logger
set log [logger::init tclircc::ui]

${log}::debug "loading tk"
package require Tk
wm withdraw .
# TODO: tray handling
# use tktray on linux, use TWAPI on windows (packaged with magicsplat)

proc tab_update {window} {
  variable toplevel_tabs

  if ![llength [set toplevel_tabs($window)]] {
    destroy $window.tabs
    destroy $window.msg1 $window.msg2

    wm title $window "tclircc"

    label $window.msg1 -text "No tabs open!"
    label $window.msg2 -text "Use the titlebar to open a new tab."

    pack configure $window.msg1 -side top -pady {20 10}
    pack configure $window.msg2 -side top -pady {10 50} -padx {50}
  }
}

proc mk_toplevel {varname {takefocus 0}} {
  variable toplevel_count
  variable toplevel_tabs
  variable version
  upvar $varname window
  set window .win$toplevel_count
  toplevel $window -takefocus $takefocus
  incr toplevel_count

  ### MENUBAR ###
  menu $window.menu
  $window configure -menu "$window.menu"

  menu $window.menu.conn
  menu $window.menu.irc
  menu $window.menu.plugin

  menu $window.menu.me
  $window.menu.me add command -label "About" -command [subst {
    tk_messageBox -title "About tclircc" \
                  -message "tclircc $version" \
                  -detail "by Aleteoryx\nhttps://amehut.dev/~aleteoryx/tclircc\n\nThis software is in the public domain." \
                  -type ok \
                  -parent $window }]

  $window.menu add cascade -label "chat" -menu $window.menu.irc
  $window.menu add cascade -label "connection" -menu $window.menu.conn
  $window.menu add cascade -label "plugins" -menu $window.menu.plugin
  $window.menu add cascade -label "tclircc" -menu $window.menu.me

  ### TABS ###
  set toplevel_tabs($window) {}

  tab_update $window
}

proc prompt {args} {
  uplevel tk_messageBox $args
}

${log}::debug "ui init done!"

#***
# [manpage_end]

D testplugin/manifest.tcl => testplugin/manifest.tcl +0 -7
@@ 1,7 0,0 @@
set name "test plugin"
set slug "test"
set namespace "aleteoryx"
set version {0 0}

set authors {Aleteoryx <alyx@aleteoryx.me>}
set license {i hate you <https://example.com> by <amity@aleteoryx.me> < > &}