The diagram below is an initial sketch, so class names may differ from the current codebase.
flowchart TD
compose_dom[Compose<br>Window<br>DOM]@{shape: circle}
ghost_server[GhostText<br>Server]@{shape: circle}
thunderbird[Thunderbird]@{shape: circle}
Port[runtime.Port]@{shape: das}
websocket[WebSocket]@{shape: das}
background[background.js<br>toplevel]@{shape: st-rect}
bg_router[[BackgroundEventRouter]]
connector[[GhostText<br>Connector]]
email_editor[[EmailEditor]]
compose_tab[compose.js<br>toplevel]@{shape: st-rect}
composeeditor[[ComposeEditor]]
gtclient[[GhostTextClient]]
port_handler[[PortHandler]]
compose_router[[ComposeEventRouter]]
option[option.js<br>toplevel]@{shape: st-rect}
option_tab[[OptionEventRouter]]
options[Options]@{shape: bow-rect}
option_holder[[OptionHolder]]
notifier[[ComposeAction<br>Notifier]]
subgraph "Options page"
option -->|Assign as a handler for various events| option_tab -.->|store| options
end
subgraph "Background Script"
background -->|Assign as a handler for various events| bg_router
bg_router -->|fwd onMessage| option_holder
bg_router -->|fwd onCommand| notifier --> gtclient --> email_editor
option_holder -.- gtclient
gtclient --> connector
websocket <-.-> connector
end
subgraph "Compose Window Script"
notecws@{ shape: tag-rect, label: "Multiple windows possible.<br>Distinguishable with tabId." }
email_editor --->|initialize<br>and kick| compose_tab
compose_tab -->|Assign as a handler for various events| compose_router
compose_router --> port_handler
port_handler --> composeeditor
end
composeeditor -..->|read/write| compose_dom
ghost_server <-....-> websocket
thunderbird -- Open --> option
thunderbird -- Load --> background
connector <-.-> Port <-.-> port_handler
Here's a sequence diagram showing interactions between background.js, compose.js and the GhostText server when the user clicks the Ghostbird button in the compose window.
sequenceDiagram
autonumber
participant S as GhostText<br>Server
participant B as background.js
participant C as compose.js
activate B
B->>B: User clicks the Ghostbird button
opt First time
B->>C: Inject compose.js
activate C
end
B->>C: Open Port
C->>B: Send initial text
B->>S: GET /
S-->>B: WebSocket port number
B->>+S: Open WebSocket connection
B->>S: Relay initial text
loop
note over S,C: Repeat the following for each edit
alt When an edit has been made on the server editor
S->>S: User edits text
S->>B: Send the edit
B->>C: Relay the edit
C->>C: Update compose window
else When edits have been made in the compose window (not until v2.0.0)
rect rgb(200,200,200)
C->>C: User edits text
C->>B: Relay the edit
B->>S: Send the edit
S->>S: Update text
end
else
Note over C,S: Until one of the following happens
else
break When the WebSocket has been closed
S->>B: Close the WebSocket
B->>C: Close the port
end
else
break When the close shortcut key is pressed
B->>B: User types the shortcut key
B->>C: Close the port
B->>S: Close the WebSocket
end
else
break When the compose window has been closed
C->>C: Compose window closed
C->>B: Close the port
B->>S: Close the WebSocket
end
else
break When background.js has been suspended
B-->>-B: Suspend (forgets everything)
C->>C: Notice that the port has closed
deactivate C
S->>-S: Notice that the WebSocket has closed
end
end
end
This is how user actions are handled:
- The Ghostbird button is clicked in the compose window.
- The background script
background.jsresponds to the event and starts a WebSocket connection to the GhostText server. (See the protocol document for details) backgroundinjectscompose.jsinto the compose window.backgroundconnects tocomposevia aPort.backgroundreads text content from the compose window.backgroundsends the text to the GhostText server via WebSocket.- The text editor shows the received text.
- Having established the connection, Thunderbird and the text editor can now synchronize text.
- When the text is changed in the text editor, the server sends the update to
background, which relays it tocompose, which updates the compose window. When the text is changed in the compose window,(Not until v2.0.0)composesends the update tobackground, which relays it to the server, which updates the text editor.
- When the text is changed in the text editor, the server sends the update to
- The WebSocket connection remains open until one of the following happens:
a)When the server closes the WebSocket connection, whichbackgrounddetects and closes the Port.b)When the shortcut key to stop is pressed,backgroundcloses both the Port and the WebSocket connection.c)When the compose window is closed, the Port closes, which is detected bybackgroundand the WebSocket connection is closed.d)When Thunderbird suspends thebackgroundscript, both the WebSocket and the Port will close.
- The compose window returns to its normal state and the button is toggled off.
- The options page is opened from Thunderbird's add-on manager.
options.jsruns and loads saved settings frombrowser.storage.local.- The user changes settings and clicks "Save".
options.jssaves the settings tobrowser.storage.local.- The next time the Ghostbird button is clicked,
background.jsreads the saved settings frombrowser.storage.localand uses them.
- Because of MV3 limitations,
background.jsmay occasionally be suspended (all variables including WebSockets are unloaded, so it's effectively terminated).- We do our best to prevent it, but ultimately it's up to Thunderbird.
- We don't implement reconnecting the WebSocket connection when it is closed abnormally. The user has to click the Ghostbird button again to reconnect.
- Connections will also close when the user updates the add-on.
- It will be handled similarly to the case
(b).
- It will be handled similarly to the case
- Initially, we don't support edits made in the compose window. We aim to support it in v2.0.0, but copying what the original GhostText add-on does might work well enough. We'll see.
-
tsc, typedoc, tsdown, Biome, and Vitest.
- See
package.json. - See CONTRIBUTING.md for the code style.
- See
-
Barrelsby generates
index.ts.- See building.md for details on build script internals.
-
We rely on GitHub Actions for CI and releases.
The code loosely follows the Ports and Adapters architecture and adheres to the Dependency Inversion Principle.
- Interface implementations are preferred over class inheritance.
- Fortunately, wrapping everything in interfaces is often unnecessary in TypeScript.
- Exported classes are preferred over exported functions, unless a function is unlikely to be swapped out for another implementation.
- That said, we don't go overboard with design patterns. We don't turn it into a Visitor just to pass a callback; feel free to use lambdas.
- I'm trying this in the hope that it makes it easier to test, extend, and maintain the code, not for philosophical reasons.
The source code is located in the src/ directory. The main files are:
src/root/background.ts: A background script that manages the WebSocket connection to the GhostText server and relays messages between the compose script and the server. Bundled asbackground.js.src/root/compose.ts: A compose script that adds a button to the mail compose window. Bundled ascompose.js.src/root/options.ts: An options page for configuring settings such as the server URL and text editor to use. Bundled asoptions.jsand used byext/options.html.
Other directories are:
src/app-background/: Infrastructure ofbackground.tsthat handles Thunderbird events.src/app-compose/: Infrastructure ofcompose.tsthat handles Thunderbird events.src/app-options/: Infrastructure ofoptions.tsthat handles Thunderbird events.src/ghosttext-session/: Main module of the add-on implementing the protocol.src/ghosttext-runner/: Facilitator that works together withghosttext-sessionby feeding events into it and execute decisions made.src/ghosttext-adaptor/: Helpers ofghosttext-runner.src/test/: Test code.src/thunderbird/: Wrapper of the Thunderbird MailExtension API.src/util/: Utility modules used by multiple modules.
The arrows in diagram below point from the dependent to the dependency.
flowchart BT
root_all@{shape: procs, label: root/}
options@{shape: procs, label: app-options/}
background@{shape: procs, label: app-background/}
compose@{shape: procs, label: app-compose/}
thunderbird@{shape: procs, label: thunderbird/}
ghosttext-runner@{shape: procs, label: ghosttext-runner/}
ghosttext-adaptor@{shape: procs, label: ghosttext-adaptor/}
ghosttext-session@{shape: procs, label: ghosttext-session/}
root_all --> thunderbird -->
compose & background & options --> ghosttext-adaptor --> ghosttext-runner --> ghosttext-session
- All
src/*/index.tsexport everything in the same folder, so practically these folders are equivalent to modules. root/contains entry points and depends on all other modules excepttest/.ghosttext-session/doesn't depend on other modules.ghosttext-adaptor/depends onghosttext-runner/, which depends onghosttext-session/.
flowchart BT
root_all@{shape: procs, label: root/}
thunderbird@{shape: procs, label: thunderbird/}
apps@{shape: procs, label: "app-*/" }
ghosts@{shape: procs, label: "ghosttext-*/" }
test@{shape: procs, label: test/ }
--> root_all & thunderbird & apps & ghosts
--> util@{shape: procs, label: util/ }
util/can be used by any modules, and they don't depend on other modules.test/can reference all other modules.
flowchart TB
root_all@{shape: procs, label: root/}
thunderbird@{shape: procs, label: thunderbird/}
messenger@{shape: pill, label: globalThis.messenger<br>(Thunderbird API)}
root_all --> thunderbird & messenger
thunderbird --> messenger
- Modules don't use Thunderbird API directly, except
root/andthunderbird/.- Other modules define subsets of the Thunderbird API they use as interfaces in
api.tsfiles in their directories, which are implemented bythunderbird/modules.
- Other modules define subsets of the Thunderbird API they use as interfaces in
flowchart TB
options@{shape: procs, label: app-options/}
background@{shape: procs, label: app-background/}
compose@{shape: procs, label: app-compose/}
thunderbird@{shape: procs, label: thunderbird/}
options -->|uses| options_api@{shape: pill, label: app-options/<br>api.ts}
background -->|uses| background_api@{shape: pill, label: app-background/<br>api.ts}
compose -->|uses| compose_api@{shape: pill, label: app-compose/<br>api.ts}
options_api & background_api & compose_api -.->|indirectly calls| thunderbird
api.tsdefines subsets of the Thunderbird API used by the module in the folder.- These interfaces are implemented by
thunderbird/modules. - This is to isolate the impact of future Thunderbird API changes to
thunderbird/modules only.
flowchart TB
ghostbird_adapter@{shape: procs, label: ghostbird-adapter/}
ghostbird_runner@{shape: procs, label: ghostbird-runner/}
ghostbird_session@{shape: procs, label: ghosttext-session/}
background@{shape: procs, label: app-background/}
compose@{shape: procs, label: app-compose/}
options@{shape: procs, label: app-options/}
ghostbird_adapter -->|uses| adapters_api@{shape: pill, label: "ghostbird_adapter/<br>api.ts"}
ghostbird_runner -->|uses| runners_api@{shape: pill, label: "ghostbird_runner/<br>api.ts"}
adapters_api -.->|indirectly calls| background & compose & options
runners_api -.->|indirectly calls| ghostbird_adapter
ghostbird_runner -->|uses| ghostbird_session
- Likewise,
ghostbird-adapter/doesn't depend onapp-*/and call them throughapi.ts. ghostbird-runner/doesn't depend onapp-adapter/and call them throughapi.tstoo.ghostbird-session/doesn't haveapi.ts, as it's the core module.
TL;DR: root/ module contains entry point and the Composition Root.
- Each of
root/startup/startup_*.tsis used in corresponding top-level module, namelybackground.ts,compose.ts, andoptions.ts. These modules initialize classes. - All the non-root classes in the codebase are either:
- A) instantiated by
startup*(), or - B) instantiated directly using the
newoperator by instances of (A).
- A) instantiated by
- All (A) classes have a property
static isSingleton: boolean. startup*()returns a factory on steroids; it scans classes that have astatic isSingletonproperty and instantiates them.- The instantiated classes are passed to the constructors of dependent classes, which must also define
static isSingleton.
- The instantiated classes are passed to the constructors of dependent classes, which must also define
static isSingleton: booleanindicates how the class should be instantiated:- If
true, only one instance is created and shared. - If
false, a new instance is created each time it is needed. - If the property is missing or contains any other value like
undefined, the class is not instantiated automatically. Attempting to request it from a class that is automatically instantiated will result in an error.
- If
- If a class wants to use another class, that class should have a field and a constructor parameter with the same name as the exported class name (case-insensitive).
- That is, constructors are expected to be simple, like
constructor(foo) { this.foo = foo; }
- That is, constructors are expected to be simple, like
- A class may also define
static wantArray = trueto allow duplicate entries:- If
true, each argument to the constructor will be an array of one or more instances that share the same name. - If the property is missing or contains any other value like
undefined, each argument to the constructor will be an instance of a class that uniquely matches that name.
- If
- A class may define
static aliases: string[] | stringto have alternative names.- Name clashes will result in an error at startup, except for those passed to classes with
wantArray = true.
- Name clashes will result in an error at startup, except for those passed to classes with
- Use of automatic instantiation must be restricted to
root/to make the code easier to follow. test/startup.test.tscontains tests to verify that all classes registered can be instantiated successfully.- See FAQ for some design decisions and justification.
flowchart LR
need_instance["Need an<br>instance"]
is_singleton{" "}
is_singleton -->|isSingleton = true| singleton["Cache the<br>instance"]
is_singleton -->|isSingleton = false| factory["Instantiate on<br>each request"]
prepare_argument{" "}
prepare_argument -->|Need arguments| want_array{" "}
prepare_argument -->|No argument<br>needed| Done@{shape: dbl-circ}
want_array -->|wantArray = false| unique_instance["Resolve the<br>unique name"]
want_array -->|wantArray = true| array_instances["Resolve all instances<br>having the same name"]
is_alias{" "}
is_alias <-->|Alias| resolve_alias["Resolve<br>the alias"] --> instantiate_class
is_alias -->|Actual class| instantiate_class["Prepare<br>the instance"]
need_instance --> is_singleton
singleton & factory --> prepare_argument
unique_instance & array_instances --> is_alias
instantiate_class --> need_instance