Add initial project structure with core functionality and basic files
This commit is contained in:
parent
b5e6fde352
commit
2bfa3b7250
53 changed files with 3057 additions and 1 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
wails3
|
||||
build/
|
||||
vendor/
|
||||
287
LICENSE.txt
Normal file
287
LICENSE.txt
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||
EUPL © the European Union 2007, 2016
|
||||
|
||||
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||
other than as authorised under this Licence is prohibited (to the extent such
|
||||
use is covered by a right of the copyright holder of the Work).
|
||||
|
||||
The Work is provided under the terms of this Licence when the Licensor (as
|
||||
defined below) has placed the following notice immediately following the
|
||||
copyright notice for the Work:
|
||||
|
||||
Licensed under the EUPL
|
||||
|
||||
or has expressed by any other means his willingness to license under the EUPL.
|
||||
|
||||
1. Definitions
|
||||
|
||||
In this Licence, the following terms have the following meaning:
|
||||
|
||||
- ‘The Licence’: this Licence.
|
||||
|
||||
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||
Licensor under this Licence, available as Source Code and also as Executable
|
||||
Code as the case may be.
|
||||
|
||||
- ‘Derivative Works’: the works or software that could be created by the
|
||||
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||
does not define the extent of modification or dependence on the Original Work
|
||||
required in order to classify a work as a Derivative Work; this extent is
|
||||
determined by copyright law applicable in the country mentioned in Article 15.
|
||||
|
||||
- ‘The Work’: the Original Work or its Derivative Works.
|
||||
|
||||
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||
convenient for people to study and modify.
|
||||
|
||||
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||
meant to be interpreted by a computer as a program.
|
||||
|
||||
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||
the Work under the Licence.
|
||||
|
||||
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||
|
||||
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||
the Work under the terms of the Licence.
|
||||
|
||||
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||
renting, distributing, communicating, transmitting, or otherwise making
|
||||
available, online or offline, copies of the Work or providing access to its
|
||||
essential functionalities at the disposal of any other natural or legal
|
||||
person.
|
||||
|
||||
2. Scope of the rights granted by the Licence
|
||||
|
||||
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
sublicensable licence to do the following, for the duration of copyright vested
|
||||
in the Original Work:
|
||||
|
||||
- use the Work in any circumstance and for all usage,
|
||||
- reproduce the Work,
|
||||
- modify the Work, and make Derivative Works based upon the Work,
|
||||
- communicate to the public, including the right to make available or display
|
||||
the Work or copies thereof to the public and perform publicly, as the case may
|
||||
be, the Work,
|
||||
- distribute the Work or copies thereof,
|
||||
- lend and rent the Work or copies thereof,
|
||||
- sublicense rights in the Work or copies thereof.
|
||||
|
||||
Those rights can be exercised on any media, supports and formats, whether now
|
||||
known or later invented, as far as the applicable law permits so.
|
||||
|
||||
In the countries where moral rights apply, the Licensor waives his right to
|
||||
exercise his moral right to the extent allowed by law in order to make effective
|
||||
the licence of the economic rights here above listed.
|
||||
|
||||
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||
any patents held by the Licensor, to the extent necessary to make use of the
|
||||
rights granted on the Work under this Licence.
|
||||
|
||||
3. Communication of the Source Code
|
||||
|
||||
The Licensor may provide the Work either in its Source Code form, or as
|
||||
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||
provides in addition a machine-readable copy of the Source Code of the Work
|
||||
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||
a notice following the copyright notice attached to the Work, a repository where
|
||||
the Source Code is easily and freely accessible for as long as the Licensor
|
||||
continues to distribute or communicate the Work.
|
||||
|
||||
4. Limitations on copyright
|
||||
|
||||
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||
any exception or limitation to the exclusive rights of the rights owners in the
|
||||
Work, of the exhaustion of those rights or of other applicable limitations
|
||||
thereto.
|
||||
|
||||
5. Obligations of the Licensee
|
||||
|
||||
The grant of the rights mentioned above is subject to some restrictions and
|
||||
obligations imposed on the Licensee. Those obligations are the following:
|
||||
|
||||
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||
trademarks notices and all notices that refer to the Licence and to the
|
||||
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||
copy of the Licence with every copy of the Work he/she distributes or
|
||||
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||
notices stating that the Work has been modified and the date of modification.
|
||||
|
||||
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||
Original Works or Derivative Works, this Distribution or Communication will be
|
||||
done under the terms of this Licence or of a later version of this Licence
|
||||
unless the Original Work is expressly distributed only under this version of the
|
||||
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||
|
||||
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||
Works or copies thereof based upon both the Work and another work licensed under
|
||||
a Compatible Licence, this Distribution or Communication can be done under the
|
||||
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||
his/her obligations under this Licence, the obligations of the Compatible
|
||||
Licence shall prevail.
|
||||
|
||||
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||
a repository where this Source will be easily and freely available for as long
|
||||
as the Licensee continues to distribute or communicate the Work.
|
||||
|
||||
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||
trademarks, service marks, or names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the copyright notice.
|
||||
|
||||
6. Chain of Authorship
|
||||
|
||||
The original Licensor warrants that the copyright in the Original Work granted
|
||||
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each time You accept the Licence, the original Licensor and subsequent
|
||||
Contributors grant You a licence to their contributions to the Work, under the
|
||||
terms of this Licence.
|
||||
|
||||
7. Disclaimer of Warranty
|
||||
|
||||
The Work is a work in progress, which is continuously improved by numerous
|
||||
Contributors. It is not a finished work and may therefore contain defects or
|
||||
‘bugs’ inherent to this type of development.
|
||||
|
||||
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||
and without warranties of any kind concerning the Work, including without
|
||||
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||
copyright as stated in Article 6 of this Licence.
|
||||
|
||||
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||
for the grant of any rights to the Work.
|
||||
|
||||
8. Disclaimer of Liability
|
||||
|
||||
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||
of the Work, including without limitation, damages for loss of goodwill, work
|
||||
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||
However, the Licensor will be liable under statutory product liability laws as
|
||||
far such laws apply to the Work.
|
||||
|
||||
9. Additional agreements
|
||||
|
||||
While distributing the Work, You may choose to conclude an additional agreement,
|
||||
defining obligations or services consistent with this Licence. However, if
|
||||
accepting obligations, You may act only on your own behalf and on your sole
|
||||
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||
for any liability incurred by, or claims asserted against such Contributor by
|
||||
the fact You have accepted any warranty or additional liability.
|
||||
|
||||
10. Acceptance of the Licence
|
||||
|
||||
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||
placed under the bottom of a window displaying the text of this Licence or by
|
||||
affirming consent in any other similar way, in accordance with the rules of
|
||||
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||
acceptance of this Licence and all of its terms and conditions.
|
||||
|
||||
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||
Distribution or Communication by You of the Work or copies thereof.
|
||||
|
||||
11. Information to the public
|
||||
|
||||
In case of any Distribution or Communication of the Work by means of electronic
|
||||
communication by You (for example, by offering to download the Work from a
|
||||
remote location) the distribution channel or media (for example, a website) must
|
||||
at least provide to the public the information requested by the applicable law
|
||||
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||
stored and reproduced by the Licensee.
|
||||
|
||||
12. Termination of the Licence
|
||||
|
||||
The Licence and the rights granted hereunder will terminate automatically upon
|
||||
any breach by the Licensee of the terms of the Licence.
|
||||
|
||||
Such a termination will not terminate the licences of any person who has
|
||||
received the Work from the Licensee under the Licence, provided such persons
|
||||
remain in full compliance with the Licence.
|
||||
|
||||
13. Miscellaneous
|
||||
|
||||
Without prejudice of Article 9 above, the Licence represents the complete
|
||||
agreement between the Parties as to the Work.
|
||||
|
||||
If any provision of the Licence is invalid or unenforceable under applicable
|
||||
law, this will not affect the validity or enforceability of the Licence as a
|
||||
whole. Such provision will be construed or reformed so as necessary to make it
|
||||
valid and enforceable.
|
||||
|
||||
The European Commission may publish other linguistic versions or new versions of
|
||||
this Licence or updated versions of the Appendix, so far this is required and
|
||||
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||
versions of the Licence will be published with a unique version number.
|
||||
|
||||
All linguistic versions of this Licence, approved by the European Commission,
|
||||
have identical value. Parties can take advantage of the linguistic version of
|
||||
their choice.
|
||||
|
||||
14. Jurisdiction
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- any litigation resulting from the interpretation of this License, arising
|
||||
between the European Union institutions, bodies, offices or agencies, as a
|
||||
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||
the Functioning of the European Union,
|
||||
|
||||
- any litigation arising between other parties and resulting from the
|
||||
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||
of the competent court where the Licensor resides or conducts its primary
|
||||
business.
|
||||
|
||||
15. Applicable Law
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- this Licence shall be governed by the law of the European Union Member State
|
||||
where the Licensor has his seat, resides or has his registered office,
|
||||
|
||||
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||
residence or registered office inside a European Union Member State.
|
||||
|
||||
Appendix
|
||||
|
||||
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||
|
||||
- GNU General Public License (GPL) v. 2, v. 3
|
||||
- GNU Affero General Public License (AGPL) v. 3
|
||||
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||
- Eclipse Public License (EPL) v. 1.0
|
||||
- CeCILL v. 2.0, v. 2.1
|
||||
- Mozilla Public Licence (MPL) v. 2
|
||||
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||
works other than software
|
||||
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||
Reciprocity (LiLiQ-R+).
|
||||
|
||||
The European Commission may update this Appendix to later versions of the above
|
||||
licences without producing a new version of the EUPL, as long as they provide
|
||||
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||
Code from exclusive appropriation.
|
||||
|
||||
All other changes or additions to this Appendix require the production of a new
|
||||
EUPL version.
|
||||
2
Makefile
Normal file
2
Makefile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
all:
|
||||
go build -o build/bin/core cmd/app/main.go
|
||||
40
README.md
40
README.md
|
|
@ -1 +1,41 @@
|
|||
# Core
|
||||
|
||||
A Helper for GoLang projects, who also use, but not exclusive to Wails.io v3+
|
||||
|
||||
You need a file called apptray.png in your assets folder
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/Snider/Core"
|
||||
"github.com/Snider/Core/display"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed all:public/*
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
|
||||
app := application.New(application.Options{
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(assets),
|
||||
},
|
||||
})
|
||||
|
||||
app.RegisterService(application.NewService(core.Service(
|
||||
core.WithWails(app), // Provides the Wails application instance to core services
|
||||
core.WithAssets(assets), // Provides the embed.FS to core services
|
||||
core.WithService(display.Register), // Provides the ability to open windows
|
||||
core.WithService(config.Register), // Provides the ability to persist UI state (windows reopen where they closed)
|
||||
core.WithServiceLock(), // locks core from accepting new services blocking access to IPC
|
||||
)))
|
||||
|
||||
err := app.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
6
Taskfile.yaml
Normal file
6
Taskfile.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- go build -o build/bin/core cmd/app/main.go
|
||||
3
actions.go
Normal file
3
actions.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package core
|
||||
|
||||
type ActionServiceStartup struct{}
|
||||
1
cmd/app/frontend/dist/assets/app.js
vendored
Normal file
1
cmd/app/frontend/dist/assets/app.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Hello from app.js!");
|
||||
BIN
cmd/app/frontend/dist/assets/apptray.png
vendored
Normal file
BIN
cmd/app/frontend/dist/assets/apptray.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
10
cmd/app/frontend/dist/index.html
vendored
Normal file
10
cmd/app/frontend/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Core</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Core</h1>
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
cmd/app/main.go
Normal file
35
cmd/app/main.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/Snider/Core"
|
||||
"github.com/Snider/Core/config"
|
||||
"github.com/Snider/Core/display"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
|
||||
app := application.New(application.Options{
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(assets),
|
||||
},
|
||||
})
|
||||
|
||||
app.RegisterService(application.NewService(core.Service(
|
||||
core.WithWails(app), // Provides the Wails application instance to core services
|
||||
core.WithAssets(assets), // Provides the embed.FS to core services
|
||||
core.WithService(display.Register), // Provides the ability to open windows
|
||||
core.WithService(config.Register), // Provides the ability to persist UI state (windows reopen where they closed)
|
||||
core.WithServiceLock(), // locks core from accepting new services blocking access to IPC
|
||||
)))
|
||||
|
||||
err := app.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
128
config/config.go
Normal file
128
config/config.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "github.com/Snider/Core"
|
||||
"github.com/adrg/xdg"
|
||||
)
|
||||
|
||||
const appName = "lethean"
|
||||
const configFileName = "config.json"
|
||||
|
||||
// ErrSetupRequired is returned by ServiceStartup if config.json is missing.
|
||||
var ErrSetupRequired = errors.New("setup required: config.json not found")
|
||||
|
||||
// Service provides access to the application's configuration.
|
||||
var service *Config
|
||||
|
||||
// NewService creates and initializes a new configuration service.
|
||||
// It loads an existing configuration or creates a default one if not found.
|
||||
func Register(c *core.Core) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve user home directory: %w", err)
|
||||
}
|
||||
userHomeDir := filepath.Join(homeDir, appName)
|
||||
configDir := filepath.Join(userHomeDir, "config")
|
||||
//configPath := filepath.Join(configDir, configFileName)
|
||||
|
||||
service = &Config{
|
||||
core: c,
|
||||
UserHomeDir: userHomeDir,
|
||||
ConfigDir: configDir,
|
||||
DataDir: filepath.Join(userHomeDir, "data"),
|
||||
WorkspacesDir: filepath.Join(userHomeDir, "workspaces"),
|
||||
DefaultRoute: "/",
|
||||
Features: []string{},
|
||||
Language: "en",
|
||||
}
|
||||
|
||||
return c.RegisterModule("config", service)
|
||||
}
|
||||
|
||||
// newDefaultConfig creates a default configuration with resolved paths and ensures directories exist.
|
||||
func newDefaultConfig() (*Config, error) {
|
||||
if strings.Contains(appName, "..") || strings.Contains(appName, string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("invalid app name '%s': contains path traversal characters", appName)
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve user home directory: %w", err)
|
||||
}
|
||||
userHomeDir := filepath.Join(homeDir, appName)
|
||||
|
||||
rootDir, err := xdg.DataFile(appName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve data directory: %w", err)
|
||||
}
|
||||
|
||||
cacheDir, err := xdg.CacheFile(appName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve cache directory: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
UserHomeDir: userHomeDir,
|
||||
RootDir: rootDir,
|
||||
CacheDir: cacheDir,
|
||||
ConfigDir: filepath.Join(userHomeDir, "config"),
|
||||
DataDir: filepath.Join(userHomeDir, "data"),
|
||||
WorkspacesDir: filepath.Join(userHomeDir, "workspaces"),
|
||||
DefaultRoute: "/",
|
||||
Features: []string{},
|
||||
Language: "en", // Hardcoded default, will be overridden if loaded or detected
|
||||
}
|
||||
|
||||
dirs := []string{cfg.RootDir, cfg.ConfigDir, cfg.DataDir, cfg.CacheDir, cfg.WorkspacesDir, cfg.UserHomeDir}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return nil, fmt.Errorf("could not create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the current configuration to config.json.
|
||||
func (c *Config) Save() error {
|
||||
configPath := filepath.Join(c.ConfigDir, configFileName)
|
||||
|
||||
data, err := json.MarshalIndent(*c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFeatureEnabled checks if a given feature is enabled in the configuration.
|
||||
func (c *Config) IsFeatureEnabled(feature string) bool {
|
||||
for _, f := range c.Features {
|
||||
if f == feature {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnableFeature adds a feature to the list of enabled features and saves the config.
|
||||
func (c *Config) EnableFeature(feature string) error {
|
||||
if c.IsFeatureEnabled(feature) {
|
||||
return nil
|
||||
}
|
||||
c.Features = append(c.Features, feature)
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save config after enabling feature %s: %w", feature, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
81
config/config_test.go
Normal file
81
config/config_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Snider/Core"
|
||||
)
|
||||
|
||||
// setupTestEnv creates a temporary home directory for testing.
|
||||
func setupTestEnv(t *testing.T) (string, func()) {
|
||||
tempHomeDir, err := os.MkdirTemp("", "test_home")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp home directory: %v", err)
|
||||
}
|
||||
|
||||
oldHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempHomeDir)
|
||||
|
||||
cleanup := func() {
|
||||
os.Setenv("HOME", oldHome)
|
||||
os.RemoveAll(tempHomeDir)
|
||||
}
|
||||
|
||||
return tempHomeDir, cleanup
|
||||
}
|
||||
|
||||
// newTestCore creates a new, empty core instance for testing.
|
||||
func newTestCore(t *testing.T) *core.Core {
|
||||
c := core.Service()
|
||||
if c == nil {
|
||||
t.Fatalf("core.Service() returned a nil instance, which is not expected for a test setup")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
tempHomeDir, cleanup := setupTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
c := newTestCore(t)
|
||||
|
||||
if err := Register(c); err != nil {
|
||||
t.Fatalf("Register() failed: %v", err)
|
||||
}
|
||||
|
||||
mod := c.Mod("config")
|
||||
if mod == nil {
|
||||
t.Fatalf("Failed to get config module from core instance")
|
||||
}
|
||||
|
||||
cfg, ok := mod.(*Config)
|
||||
if !ok {
|
||||
t.Fatalf("Module is not of type *Config")
|
||||
}
|
||||
|
||||
expectedUserHomeDir := filepath.Join(tempHomeDir, appName)
|
||||
expectedConfigDir := filepath.Join(expectedUserHomeDir, "config")
|
||||
expectedDataDir := filepath.Join(expectedUserHomeDir, "data")
|
||||
expectedWorkspacesDir := filepath.Join(expectedUserHomeDir, "workspaces")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actual string
|
||||
expected string
|
||||
}{
|
||||
{"UserHomeDir", cfg.UserHomeDir, expectedUserHomeDir},
|
||||
{"ConfigDir", cfg.ConfigDir, expectedConfigDir},
|
||||
{"DataDir", cfg.DataDir, expectedDataDir},
|
||||
{"WorkspacesDir", cfg.WorkspacesDir, expectedWorkspacesDir},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.actual != tt.expected {
|
||||
t.Errorf("Mismatch for %s: got %q, want %q", tt.name, tt.actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
config/header.go
Normal file
55
config/header.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
core "github.com/Snider/Core"
|
||||
)
|
||||
|
||||
// Config holds the resolved paths and user-configurable settings for the application.
|
||||
type Config struct {
|
||||
// --- Dynamic Paths (not stored in config.json) ---
|
||||
core *core.Core
|
||||
DataDir string `json:"-"`
|
||||
ConfigDir string `json:"-"`
|
||||
CacheDir string `json:"-"`
|
||||
WorkspacesDir string `json:"-"`
|
||||
RootDir string `json:"-"`
|
||||
UserHomeDir string `json:"-"`
|
||||
IsNew bool `json:"-"` // Flag indicating if the config was newly created.
|
||||
|
||||
// --- Storable Settings (persisted in config.json) ---
|
||||
DefaultRoute string `json:"defaultRoute,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// Key retrieves a configuration value by its key. It checks JSON tags and field names (case-insensitive).
|
||||
func (c *Config) Key(key string) (interface{}, error) {
|
||||
// Use reflection to inspect the struct fields.
|
||||
val := reflect.ValueOf(c).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
fieldName := field.Name
|
||||
|
||||
// Check the field name first.
|
||||
if strings.EqualFold(fieldName, key) {
|
||||
return val.Field(i).Interface(), nil
|
||||
}
|
||||
|
||||
// Then check the `json` tag.
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag != "" && jsonTag != "-" {
|
||||
jsonName := strings.Split(jsonTag, ",")[0]
|
||||
if strings.EqualFold(jsonName, key) {
|
||||
return val.Field(i).Interface(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("key '%s' not found in config", key)
|
||||
}
|
||||
139
core.go
Normal file
139
core.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// Service initialises a Core instance using the provided options and performs the necessary setup.
|
||||
func Service(opts ...Option) *Core {
|
||||
c := &Core{
|
||||
mods: make(map[string]any),
|
||||
}
|
||||
// Apply all options (including WithService calls)
|
||||
for _, o := range opts {
|
||||
if err := o(c); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.once.Do(func() {
|
||||
// any one‑time initialisation you need
|
||||
instance = c
|
||||
c.initErr = nil
|
||||
})
|
||||
if c.initErr != nil {
|
||||
return nil
|
||||
}
|
||||
if c.serviceLock {
|
||||
c.servicesLocked = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WithService wraps a function that registers a package or module with the provided Core instance as an Option.
|
||||
func WithService(reg func(*Core) error) Option {
|
||||
return func(c *Core) error {
|
||||
return reg(c)
|
||||
}
|
||||
}
|
||||
|
||||
// WithWails sets the Wails application instance to the Core configuration and returns an Option function.
|
||||
func WithWails(app *application.App) Option {
|
||||
return func(c *Core) error {
|
||||
c.App = app
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAssets sets the provided embedded filesystem as the assets for the Core instance.
|
||||
func WithAssets(fs embed.FS) Option {
|
||||
return func(c *Core) error {
|
||||
c.assets = fs
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithServiceLock() Option {
|
||||
return func(c *Core) error {
|
||||
c.serviceLock = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup initializes the service during application startup by executing the ActionServiceStartup message.
|
||||
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
return c.ACTION(ActionServiceStartup{})
|
||||
}
|
||||
|
||||
// ACTION processes a Message by invoking all registered handlers and returns an aggregated error if any handlers fail.
|
||||
func (c *Core) ACTION(msg Message) error {
|
||||
c.ipcMu.RLock()
|
||||
handlers := append([]func(*Core, Message) error(nil), c.ipcHandlers...)
|
||||
c.ipcMu.RUnlock()
|
||||
|
||||
var agg error
|
||||
for _, h := range handlers {
|
||||
if err := h(c, msg); err != nil {
|
||||
agg = fmt.Errorf("%w; %v", agg, err)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// RegisterAction adds a single handler function to the list of registered IPC handlers in a thread-safe manner.
|
||||
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
|
||||
c.ipcMu.Lock()
|
||||
c.ipcHandlers = append(c.ipcHandlers, handler)
|
||||
c.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterActions registers multiple IPC handler functions to be executed during message processing in a thread-safe manner.
|
||||
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
|
||||
c.ipcMu.Lock()
|
||||
c.ipcHandlers = append(c.ipcHandlers, handlers...)
|
||||
c.ipcMu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterModule inserts an API object under a unique name.
|
||||
func (c *Core) RegisterModule(name string, api any) error {
|
||||
|
||||
if c.servicesLocked {
|
||||
return fmt.Errorf("core: module %q is not permitted by the serviceLock setting", name)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return errors.New("core: module name cannot be empty")
|
||||
}
|
||||
c.modMu.Lock()
|
||||
defer c.modMu.Unlock()
|
||||
if _, exists := c.mods[name]; exists {
|
||||
return fmt.Errorf("core: module %q already registered", name)
|
||||
}
|
||||
c.mods[name] = api
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mod caller must type‑assert the result to the concrete API type it expects.
|
||||
func (c *Core) Mod(name string) any {
|
||||
c.modMu.RLock()
|
||||
api, ok := c.mods[name]
|
||||
c.modMu.RUnlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
// Mod is a generic helper to get a module of expected type T.
|
||||
func Mod[T any](c *Core, name string) *T {
|
||||
raw := c.Mod(name)
|
||||
typed, ok := raw.(*T)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return typed
|
||||
}
|
||||
23
crypt/crypt.go
Normal file
23
crypt/crypt.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"github.com/Snider/Core/config"
|
||||
)
|
||||
|
||||
// HashType defines the supported hashing algorithms.
|
||||
type HashType string
|
||||
|
||||
const (
|
||||
LTHN HashType = "lthn"
|
||||
SHA512 HashType = "sha512"
|
||||
SHA256 HashType = "sha256"
|
||||
SHA1 HashType = "sha1"
|
||||
MD5 HashType = "md5"
|
||||
)
|
||||
|
||||
// Service provides cryptographic functions.
|
||||
// It is the main entry point for all cryptographic operations
|
||||
// and is bound to the frontend.
|
||||
type Service struct {
|
||||
config *config.Config
|
||||
}
|
||||
20
crypt/crypt_test.go
Normal file
20
crypt/crypt_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
s := &Service{}
|
||||
payload := "hello"
|
||||
hash := s.Hash(LTHN, payload)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
func TestLuhn(t *testing.T) {
|
||||
s := &Service{}
|
||||
assert.True(t, s.Luhn("79927398713"))
|
||||
assert.False(t, s.Luhn("79927398714"))
|
||||
}
|
||||
33
crypt/hash.go
Normal file
33
crypt/hash.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/Snider/Core/crypt/lib/lthn"
|
||||
)
|
||||
|
||||
// Hash computes a hash of the payload using the specified algorithm.
|
||||
func (s *Service) Hash(lib HashType, payload string) string {
|
||||
switch lib {
|
||||
case LTHN:
|
||||
return lthn.Hash(payload)
|
||||
case SHA512:
|
||||
hash := sha512.Sum512([]byte(payload))
|
||||
return hex.EncodeToString(hash[:])
|
||||
case SHA1:
|
||||
hash := sha1.Sum([]byte(payload))
|
||||
return hex.EncodeToString(hash[:])
|
||||
case MD5:
|
||||
hash := md5.Sum([]byte(payload))
|
||||
return hex.EncodeToString(hash[:])
|
||||
case SHA256:
|
||||
fallthrough
|
||||
default:
|
||||
hash := sha256.Sum256([]byte(payload))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
}
|
||||
46
crypt/lib/lthn/hash.go
Normal file
46
crypt/lib/lthn/hash.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package lthn
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// SetKeyMap sets the key map for the notarisation process.
|
||||
func SetKeyMap(newKeyMap map[rune]rune) {
|
||||
keyMap = newKeyMap
|
||||
}
|
||||
|
||||
// GetKeyMap gets the current key map.
|
||||
func GetKeyMap() map[rune]rune {
|
||||
return keyMap
|
||||
}
|
||||
|
||||
// Hash creates a reproducible hash from a string.
|
||||
func Hash(input string) string {
|
||||
salt := createSalt(input)
|
||||
hash := sha256.Sum256([]byte(input + salt))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// createSalt creates a quasi-salt from a string by reversing it and swapping characters.
|
||||
func createSalt(input string) string {
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(input)
|
||||
salt := make([]rune, len(runes))
|
||||
for i := 0; i < len(runes); i++ {
|
||||
char := runes[len(runes)-1-i]
|
||||
if replacement, ok := keyMap[char]; ok {
|
||||
salt[i] = replacement
|
||||
} else {
|
||||
salt[i] = char
|
||||
}
|
||||
}
|
||||
return string(salt)
|
||||
}
|
||||
|
||||
// Verify checks if an input string matches a given hash.
|
||||
func Verifyf(input string, hash string) bool {
|
||||
return Hash(input) == hash
|
||||
}
|
||||
48
crypt/lib/lthn/hash_test.go
Normal file
48
crypt/lib/lthn/hash_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package lthn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
input := "test_string"
|
||||
expectedHash := "45d4027179b17265c38732fb1e7089a0b1adfe1d3ba4105fce66f7d46ba42f7d"
|
||||
|
||||
hashed := Hash(input)
|
||||
fmt.Printf("Hash for \"%s\": %s\n", input, hashed)
|
||||
|
||||
assert.Equal(t, expectedHash, hashed, "The hash should match the expected value")
|
||||
}
|
||||
|
||||
func TestCreateSalt(t *testing.T) {
|
||||
// Test with default keyMap
|
||||
SetKeyMap(map[rune]rune{})
|
||||
assert.Equal(t, "gnirts_tset", createSalt("test_string"))
|
||||
assert.Equal(t, "", createSalt(""))
|
||||
assert.Equal(t, "A", createSalt("A"))
|
||||
|
||||
// Test with a custom keyMap
|
||||
customKeyMap := map[rune]rune{
|
||||
'a': 'x',
|
||||
'b': 'y',
|
||||
'c': 'z',
|
||||
}
|
||||
SetKeyMap(customKeyMap)
|
||||
assert.Equal(t, "zyx", createSalt("abc"))
|
||||
assert.Equal(t, "gnirts_tset", createSalt("test_string")) // 'test_string' doesn't have 'a', 'b', 'c'
|
||||
|
||||
// Reset keyMap to default for other tests
|
||||
SetKeyMap(map[rune]rune{})
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
input := "another_test_string"
|
||||
hashed := Hash(input)
|
||||
|
||||
assert.True(t, Verifyf(input, hashed), "Verifyf should return true for a matching hash")
|
||||
assert.False(t, Verifyf(input, "wrong_hash"), "Verifyf should return false for a non-matching hash")
|
||||
assert.False(t, Verifyf("different_input", hashed), "Verifyf should return false for different input")
|
||||
}
|
||||
16
crypt/lib/lthn/lthn.go
Normal file
16
crypt/lib/lthn/lthn.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package lthn
|
||||
|
||||
// keyMap is the default character-swapping map used for the quasi-salting process.
|
||||
var keyMap = map[rune]rune{
|
||||
'o': '0',
|
||||
'l': '1',
|
||||
'e': '3',
|
||||
'a': '4',
|
||||
's': 'z',
|
||||
't': '7',
|
||||
'0': 'o',
|
||||
'1': 'l',
|
||||
'3': 'e',
|
||||
'4': 'a',
|
||||
'7': 't',
|
||||
}
|
||||
106
crypt/lib/openpgp/encrypt.go
Normal file
106
crypt/lib/openpgp/encrypt.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package openpgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
)
|
||||
|
||||
// EncryptPGP encrypts data for a recipient, optionally signing it.
|
||||
func EncryptPGP(medium filesystem.Medium, recipientPath, data string, signerPath, signerPassphrase *string) (string, error) {
|
||||
recipient, err := GetPublicKey(medium, recipientPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get recipient public key: %w", err)
|
||||
}
|
||||
|
||||
var signer *openpgp.Entity
|
||||
if signerPath != nil && signerPassphrase != nil {
|
||||
signer, err = GetPrivateKey(medium, *signerPath, *signerPassphrase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get private key for signing: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
armoredWriter, err := armor.Encode(buf, pgpMessageHeader, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create armored writer: %w", err)
|
||||
}
|
||||
|
||||
plaintextWriter, err := openpgp.Encrypt(armoredWriter, []*openpgp.Entity{recipient}, signer, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt: %w", err)
|
||||
}
|
||||
|
||||
if _, err := plaintextWriter.Write([]byte(data)); err != nil {
|
||||
return "", fmt.Errorf("failed to write plaintext data: %w", err)
|
||||
}
|
||||
|
||||
if err := plaintextWriter.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close plaintext writer: %w", err)
|
||||
}
|
||||
if err := armoredWriter.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close armored writer: %w", err)
|
||||
}
|
||||
|
||||
// Debug print the encrypted message
|
||||
fmt.Printf("Encrypted Message:\n%s\n", buf.String())
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// DecryptPGP decrypts a PGP message, optionally verifying the signature.
|
||||
func DecryptPGP(medium filesystem.Medium, recipientPath, message, passphrase string, signerPath *string) (string, error) {
|
||||
privateKeyEntity, err := GetPrivateKey(medium, recipientPath, passphrase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get private key: %w", err)
|
||||
}
|
||||
|
||||
// For this API version, the keyring must contain all keys for decryption and verification.
|
||||
keyring := openpgp.EntityList{privateKeyEntity}
|
||||
var expectedSigner *openpgp.Entity
|
||||
|
||||
if signerPath != nil {
|
||||
publicKeyEntity, err := GetPublicKey(medium, *signerPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get public key for verification: %w", err)
|
||||
}
|
||||
keyring = append(keyring, publicKeyEntity)
|
||||
expectedSigner = publicKeyEntity
|
||||
}
|
||||
|
||||
// Debug print the message before decryption
|
||||
fmt.Printf("Message to Decrypt:\n%s\n", message)
|
||||
|
||||
// We pass the combined keyring, and nil for the prompt function because the private key is already decrypted.
|
||||
md, err := openpgp.ReadMessage(strings.NewReader(message), keyring, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read PGP message: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := io.ReadAll(md.UnverifiedBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read decrypted body: %w", err)
|
||||
}
|
||||
|
||||
// The signature is checked automatically if the public key is in the keyring.
|
||||
// We still need to check for errors and that the signer was who we expected.
|
||||
if signerPath != nil {
|
||||
if md.SignatureError != nil {
|
||||
return "", fmt.Errorf("signature verification failed: %w", md.SignatureError)
|
||||
}
|
||||
if md.SignedBy == nil {
|
||||
return "", fmt.Errorf("message is not signed, but signature verification was requested")
|
||||
}
|
||||
if expectedSigner.PrimaryKey.KeyId != md.SignedBy.PublicKey.KeyId {
|
||||
return "", fmt.Errorf("signature from unexpected key id: got %X, want %X", md.SignedBy.PublicKey.KeyId, expectedSigner.PrimaryKey.KeyId)
|
||||
}
|
||||
}
|
||||
|
||||
return string(decrypted), nil
|
||||
}
|
||||
226
crypt/lib/openpgp/key.go
Normal file
226
crypt/lib/openpgp/key.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package openpgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
"github.com/Snider/Core/crypt/lib/lthn"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
)
|
||||
|
||||
// CreateKeyPair generates a new OpenPGP key pair.
|
||||
// The password parameter is optional. If not provided, the private key will not be encrypted.
|
||||
func CreateKeyPair(username string, passwords ...string) (*KeyPair, error) {
|
||||
var password string
|
||||
if len(passwords) > 0 {
|
||||
password = passwords[0]
|
||||
}
|
||||
|
||||
entity, err := openpgp.NewEntity(username, "Lethean Desktop", "", &packet.Config{
|
||||
RSABits: 4096,
|
||||
DefaultHash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new entity: %w", err)
|
||||
}
|
||||
|
||||
// The private key is initially unencrypted after NewEntity.
|
||||
// Generate revocation certificate while the private key is unencrypted.
|
||||
revocationCert, err := createRevocationCertificate(entity)
|
||||
if err != nil {
|
||||
revocationCert = "" // Non-critical, proceed without it if it fails
|
||||
}
|
||||
|
||||
// Encrypt the private key only if a password is provided, after revocation cert generation.
|
||||
if password != "" {
|
||||
if err := entity.PrivateKey.Encrypt([]byte(password)); err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
publicKey, err := serializeEntity(entity, openpgp.PublicKeyType, "") // Public key doesn't need password
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Private key serialization. The key is already in its final encrypted/unencrypted state.
|
||||
privateKey, err := serializeEntity(entity, openpgp.PrivateKeyType, "") // No password needed here for serialization
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &KeyPair{
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
RevocationCertificate: revocationCert,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateServerKeyPair creates and stores a key pair for the server in a specific directory.
|
||||
func CreateServerKeyPair(keysDir string) error {
|
||||
serverKeyPath := filepath.Join(keysDir, "server.lthn.pub")
|
||||
// Passphrase is derived from the path itself, consistent with original logic.
|
||||
passphrase := lthn.Hash(serverKeyPath)
|
||||
return createAndStoreKeyPair("server", passphrase, keysDir)
|
||||
}
|
||||
|
||||
// GetPublicKey retrieves an armored public key for a given ID.
|
||||
func GetPublicKey(medium filesystem.Medium, path string) (*openpgp.Entity, error) {
|
||||
return readEntity(medium, path)
|
||||
}
|
||||
|
||||
// GetPrivateKey retrieves and decrypts an armored private key.
|
||||
func GetPrivateKey(medium filesystem.Medium, path, passphrase string) (*openpgp.Entity, error) {
|
||||
entity, err := readEntity(medium, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entity.PrivateKey == nil {
|
||||
return nil, fmt.Errorf("no private key found for path %s", path)
|
||||
}
|
||||
|
||||
if entity.PrivateKey.Encrypted {
|
||||
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key for path %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
var primaryIdentity *openpgp.Identity
|
||||
for _, identity := range entity.Identities {
|
||||
if identity.SelfSignature.IsPrimaryId != nil && *identity.SelfSignature.IsPrimaryId {
|
||||
primaryIdentity = identity
|
||||
break
|
||||
}
|
||||
}
|
||||
if primaryIdentity == nil {
|
||||
for _, identity := range entity.Identities {
|
||||
primaryIdentity = identity
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if primaryIdentity == nil {
|
||||
return nil, fmt.Errorf("key for %s has no identity", path)
|
||||
}
|
||||
|
||||
if primaryIdentity.SelfSignature.KeyLifetimeSecs != nil {
|
||||
if primaryIdentity.SelfSignature.CreationTime.Add(time.Duration(*primaryIdentity.SelfSignature.KeyLifetimeSecs) * time.Second).Before(time.Now()) {
|
||||
return nil, fmt.Errorf("key for %s has expired", path)
|
||||
}
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
func createAndStoreKeyPair(id, password, dir string) error {
|
||||
var keyPair *KeyPair
|
||||
var err error
|
||||
|
||||
if password != "" {
|
||||
keyPair, err = CreateKeyPair(id, password)
|
||||
} else {
|
||||
keyPair, err = CreateKeyPair(id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create key pair for id %s: %w", id, err)
|
||||
}
|
||||
|
||||
if err := filesystem.Local.EnsureDir(dir); err != nil {
|
||||
return fmt.Errorf("failed to ensure key directory exists: %w", err)
|
||||
}
|
||||
|
||||
files := map[string]string{
|
||||
filepath.Join(dir, fmt.Sprintf("%s.lthn.pub", id)): keyPair.PublicKey,
|
||||
filepath.Join(dir, fmt.Sprintf("%s.lthn.key", id)): keyPair.PrivateKey,
|
||||
filepath.Join(dir, fmt.Sprintf("%s.lthn.rev", id)): keyPair.RevocationCertificate, // Re-enabled
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if err := filesystem.Local.Write(path, content); err != nil {
|
||||
return fmt.Errorf("failed to write key file %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readEntity(m filesystem.Medium, path string) (*openpgp.Entity, error) {
|
||||
keyArmored, err := m.Read(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key file %s: %w", path, err)
|
||||
}
|
||||
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(keyArmored))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key file %s: %w", path, err)
|
||||
}
|
||||
if len(entityList) == 0 {
|
||||
return nil, fmt.Errorf("no entity found in key file %s", path)
|
||||
}
|
||||
return entityList[0], nil
|
||||
}
|
||||
|
||||
func serializeEntity(entity *openpgp.Entity, keyType string, password string) (string, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
writer, err := armor.Encode(buf, keyType, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create armor encoder: %w", err)
|
||||
}
|
||||
|
||||
if keyType == openpgp.PrivateKeyType {
|
||||
// Serialize the private key in its current in-memory state.
|
||||
// Encryption is handled by CreateKeyPair before this function is called.
|
||||
err = entity.SerializePrivateWithoutSigning(writer, nil)
|
||||
} else {
|
||||
err = entity.Serialize(writer)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize entity: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close armor writer: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func createRevocationCertificate(entity *openpgp.Entity) (string, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
writer, err := armor.Encode(buf, openpgp.SignatureType, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create armor encoder for revocation: %w", err)
|
||||
}
|
||||
|
||||
sig := &packet.Signature{
|
||||
SigType: packet.SigTypeKeyRevocation,
|
||||
PubKeyAlgo: entity.PrimaryKey.PubKeyAlgo,
|
||||
Hash: crypto.SHA256,
|
||||
CreationTime: time.Now(),
|
||||
IssuerKeyId: &entity.PrimaryKey.KeyId,
|
||||
}
|
||||
|
||||
// SignKey requires an unencrypted private key.
|
||||
if err := sig.SignKey(entity.PrimaryKey, entity.PrivateKey, nil); err != nil {
|
||||
return "", fmt.Errorf("failed to sign revocation: %w", err)
|
||||
}
|
||||
if err := sig.Serialize(writer); err != nil {
|
||||
return "", fmt.Errorf("failed to serialize revocation signature: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close revocation writer: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
12
crypt/lib/openpgp/openpgp.go
Normal file
12
crypt/lib/openpgp/openpgp.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package openpgp
|
||||
|
||||
// pgpMessageHeader is the standard armor header for PGP messages.
|
||||
const pgpMessageHeader = "PGP MESSAGE"
|
||||
|
||||
// KeyPair holds the generated armored keys and revocation certificate.
|
||||
// This is the primary data structure representing a user's PGP identity within the system.
|
||||
type KeyPair struct {
|
||||
PublicKey string
|
||||
PrivateKey string
|
||||
RevocationCertificate string
|
||||
}
|
||||
39
crypt/lib/openpgp/sign.go
Normal file
39
crypt/lib/openpgp/sign.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package openpgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
)
|
||||
|
||||
// Sign creates a detached signature for the data.
|
||||
func Sign(medium filesystem.Medium, data, privateKeyPath, passphrase string) (string, error) {
|
||||
signer, err := GetPrivateKey(medium, privateKeyPath, passphrase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get private key for signing: %w", err)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := openpgp.ArmoredDetachSign(buf, signer, strings.NewReader(data), nil); err != nil {
|
||||
return "", fmt.Errorf("failed to create detached signature: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// Verify checks a detached signature.
|
||||
func Verify(medium filesystem.Medium, data, signature, publicKeyPath string) (bool, error) {
|
||||
keyring, err := GetPublicKey(medium, publicKeyPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get public key for verification: %w", err)
|
||||
}
|
||||
|
||||
_, err = openpgp.CheckArmoredDetachedSignature(openpgp.EntityList{keyring}, strings.NewReader(data), strings.NewReader(signature), nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
43
crypt/service.go
Normal file
43
crypt/service.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Snider/Core/config"
|
||||
"github.com/Snider/Core/crypt/lib/openpgp"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// createServerKeyPair is a package-level variable that can be swapped for testing.
|
||||
var createServerKeyPair = openpgp.CreateServerKeyPair
|
||||
|
||||
// NewService creates a new crypt.Service, accepting a config service instance.
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup Startup is called when the app starts. It handles one-time cryptographic setup.
|
||||
func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
// Define the directory for server keys based on the central config.
|
||||
serverKeysDir := filepath.Join(s.config.DataDir, "server_keys")
|
||||
if err := filesystem.EnsureDir(filesystem.Local, serverKeysDir); err != nil {
|
||||
return fmt.Errorf("failed to create server keys directory: %w", err)
|
||||
}
|
||||
|
||||
// Check for server key pair using the configured path.
|
||||
serverKeyPath := filepath.Join(serverKeysDir, "server.lthn.pub")
|
||||
if !filesystem.IsFile(filesystem.Local, serverKeyPath) {
|
||||
log.Println("Creating server key pair...")
|
||||
if err := createServerKeyPair(serverKeysDir); err != nil {
|
||||
return fmt.Errorf("failed to create server key pair: %w", err)
|
||||
}
|
||||
log.Println("Server key pair created.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
crypt/sum.go
Normal file
77
crypt/sum.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Luhn validates a number using the Luhn algorithm.
|
||||
func (s *Service) Luhn(payload string) bool {
|
||||
payload = strings.ReplaceAll(payload, " ", "")
|
||||
sum := 0
|
||||
isSecond := false
|
||||
for i := len(payload) - 1; i >= 0; i-- {
|
||||
digit, err := strconv.Atoi(string(payload[i]))
|
||||
if err != nil {
|
||||
return false // Contains non-digit
|
||||
}
|
||||
|
||||
if isSecond {
|
||||
digit = digit * 2
|
||||
if digit > 9 {
|
||||
digit = digit - 9
|
||||
}
|
||||
}
|
||||
|
||||
sum += digit
|
||||
isSecond = !isSecond
|
||||
}
|
||||
return sum%10 == 0
|
||||
}
|
||||
|
||||
// Fletcher16 computes the Fletcher-16 checksum.
|
||||
func (s *Service) Fletcher16(payload string) uint16 {
|
||||
data := []byte(payload)
|
||||
var sum1, sum2 uint16
|
||||
for _, b := range data {
|
||||
sum1 = (sum1 + uint16(b)) % 255
|
||||
sum2 = (sum2 + sum1) % 255
|
||||
}
|
||||
return (sum2 << 8) | sum1
|
||||
}
|
||||
|
||||
// Fletcher32 computes the Fletcher-32 checksum.
|
||||
func (s *Service) Fletcher32(payload string) uint32 {
|
||||
data := []byte(payload)
|
||||
// Pad with 0 to make it even length for uint16 conversion
|
||||
if len(data)%2 != 0 {
|
||||
data = append(data, 0)
|
||||
}
|
||||
|
||||
var sum1, sum2 uint32
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
val := binary.LittleEndian.Uint16(data[i : i+2])
|
||||
sum1 = (sum1 + uint32(val)) % 65535
|
||||
sum2 = (sum2 + sum1) % 65535
|
||||
}
|
||||
return (sum2 << 16) | sum1
|
||||
}
|
||||
|
||||
// Fletcher64 computes the Fletcher-64 checksum.
|
||||
func (s *Service) Fletcher64(payload string) uint64 {
|
||||
data := []byte(payload)
|
||||
// Pad to multiple of 4
|
||||
if len(data)%4 != 0 {
|
||||
padding := 4 - (len(data) % 4)
|
||||
data = append(data, make([]byte, padding)...)
|
||||
}
|
||||
|
||||
var sum1, sum2 uint64
|
||||
for i := 0; i < len(data); i += 4 {
|
||||
val := binary.LittleEndian.Uint32(data[i : i+4])
|
||||
sum1 = (sum1 + uint64(val)) % 4294967295
|
||||
sum2 = (sum2 + sum1) % 4294967295
|
||||
}
|
||||
return (sum2 << 32) | sum1
|
||||
}
|
||||
154
display/display.go
Normal file
154
display/display.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Snider/Core"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
type ActionOpenWindow struct {
|
||||
Target string
|
||||
}
|
||||
|
||||
var instance *API
|
||||
|
||||
func Register(c *core.Core) error {
|
||||
instance = &API{
|
||||
core: c,
|
||||
windowHandles: make(map[string]*application.WebviewWindow),
|
||||
}
|
||||
if err := c.RegisterModule("display", instance); err != nil {
|
||||
return err
|
||||
}
|
||||
c.RegisterAction(handleActionCall)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleActionCall(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case *ActionOpenWindow:
|
||||
instance.OpenWindow(m.Target, application.WebviewWindowOptions{
|
||||
Title: "Core",
|
||||
Height: 900,
|
||||
Width: 1280,
|
||||
URL: m.Target,
|
||||
})
|
||||
return nil
|
||||
case core.ActionServiceStartup:
|
||||
err := instance.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
c.App.Logger.Error("Unknown message type", "type", fmt.Sprintf("%T", m))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *API) analyzeScreens() {
|
||||
d.core.App.Logger.Info("Screen analysis", "count", len(d.core.App.Screen.GetAll()))
|
||||
|
||||
primary := d.core.App.Screen.GetPrimary()
|
||||
if primary != nil {
|
||||
d.core.App.Logger.Info("Primary screen",
|
||||
"name", primary.Name,
|
||||
"size", fmt.Sprintf("%dx%d", primary.Size.Width, primary.Size.Height),
|
||||
"scaleFactor", primary.ScaleFactor,
|
||||
"workArea", primary.WorkArea,
|
||||
)
|
||||
scaleFactor := primary.ScaleFactor
|
||||
|
||||
switch {
|
||||
case scaleFactor == 1.0:
|
||||
d.core.App.Logger.Info("Standard DPI display", "screen", primary.Name)
|
||||
case scaleFactor == 1.25:
|
||||
d.core.App.Logger.Info("125% scaled display", "screen", primary.Name)
|
||||
case scaleFactor == 1.5:
|
||||
d.core.App.Logger.Info("150% scaled display", "screen", primary.Name)
|
||||
case scaleFactor == 2.0:
|
||||
d.core.App.Logger.Info("High DPI display (200%)", "screen", primary.Name)
|
||||
default:
|
||||
d.core.App.Logger.Info("Custom scale display",
|
||||
"screen", primary.Name,
|
||||
"scale", scaleFactor,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
d.core.App.Logger.Info("No primary screen found")
|
||||
}
|
||||
|
||||
for i, screen := range d.core.App.Screen.GetAll() {
|
||||
d.core.App.Logger.Info("Screen details",
|
||||
"index", i,
|
||||
"name", screen.Name,
|
||||
"primary", screen.IsPrimary,
|
||||
"bounds", screen.Bounds,
|
||||
"scaleFactor", screen.ScaleFactor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *API) monitorScreenChanges() {
|
||||
// Monitor for screen configuration changes
|
||||
d.core.App.Event.OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) {
|
||||
d.core.App.Logger.Info("Screen configuration changed")
|
||||
|
||||
// Re-analyze screens
|
||||
d.core.App.Logger.Info("Updated screen count", "count", len(d.core.App.Screen.GetAll()))
|
||||
|
||||
// Could reposition windows here if needed
|
||||
})
|
||||
}
|
||||
|
||||
func (d *API) ShowEnvironmentDialog() {
|
||||
envInfo := d.core.App.Env.Info()
|
||||
|
||||
details := fmt.Sprintf(`Environment Information:
|
||||
|
||||
Operating System: %s
|
||||
Architecture: %s
|
||||
Debug Mode: %t
|
||||
|
||||
Dark Mode: %t
|
||||
|
||||
Platform Information:`,
|
||||
envInfo.OS,
|
||||
envInfo.Arch,
|
||||
envInfo.Debug,
|
||||
d.core.App.Env.IsDarkMode()) // Use d.core.App
|
||||
|
||||
// Add platform-specific details
|
||||
for key, value := range envInfo.PlatformInfo {
|
||||
details += fmt.Sprintf("\n%s: %v", key, value)
|
||||
}
|
||||
|
||||
if envInfo.OSInfo != nil {
|
||||
details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s",
|
||||
envInfo.OSInfo.Name,
|
||||
envInfo.OSInfo.Version)
|
||||
}
|
||||
|
||||
dialog := d.core.App.Dialog.Info()
|
||||
dialog.SetTitle("Environment Information")
|
||||
dialog.SetMessage(details)
|
||||
dialog.Show()
|
||||
}
|
||||
|
||||
func (d *API) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
d.core.App.Logger.Info("Display service starting up")
|
||||
d.analyzeScreens()
|
||||
d.monitorScreenChanges()
|
||||
d.buildMenu()
|
||||
d.systemTray()
|
||||
d.core.App.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "Core",
|
||||
Height: 900,
|
||||
Width: 1280,
|
||||
URL: "/",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
26
display/header.go
Normal file
26
display/header.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"github.com/Snider/Core"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// Brand defines the type for different application brands.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
AdminHub Brand = "admin-hub"
|
||||
ServerHub Brand = "server-hub"
|
||||
GatewayHub Brand = "gateway-hub"
|
||||
DeveloperHub Brand = "developer-hub"
|
||||
ClientHub Brand = "client-hub"
|
||||
)
|
||||
|
||||
// Service manages all OS-level UI interactions (menus, windows, tray).
|
||||
// It is the main entry point for all display-related operations.
|
||||
type API struct {
|
||||
// --- Injected Dependencies ---
|
||||
core *core.Core
|
||||
|
||||
windowHandles map[string]*application.WebviewWindow
|
||||
}
|
||||
32
display/menu.go
Normal file
32
display/menu.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// buildMenu creates and sets the main application menu.
|
||||
func (d *API) buildMenu() {
|
||||
appMenu := d.core.App.Menu.New()
|
||||
if runtime.GOOS == "darwin" {
|
||||
appMenu.AddRole(application.AppMenu)
|
||||
}
|
||||
appMenu.AddRole(application.FileMenu)
|
||||
appMenu.AddRole(application.ViewMenu)
|
||||
appMenu.AddRole(application.EditMenu)
|
||||
|
||||
workspace := appMenu.AddSubmenu("Workspace")
|
||||
workspace.Add("New").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
workspace.Add("List").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
|
||||
// Add brand-specific menu items
|
||||
//if s.brand == DeveloperHub {
|
||||
// appMenu.AddSubmenu("Developer")
|
||||
//}
|
||||
|
||||
appMenu.AddRole(application.WindowMenu)
|
||||
appMenu.AddRole(application.HelpMenu)
|
||||
|
||||
d.core.App.Menu.Set(appMenu)
|
||||
}
|
||||
71
display/tray.go
Normal file
71
display/tray.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// setupTray configures and creates the system tray icon and menu.
|
||||
func (d *API) systemTray() {
|
||||
|
||||
systray := d.core.App.SystemTray.New()
|
||||
systray.SetTooltip("Lethean Desktop")
|
||||
systray.SetLabel("hey")
|
||||
//appTrayIcon, _ := d.assets.ReadFile("assets/apptray.png")
|
||||
//
|
||||
//if runtime.GOOS == "darwin" {
|
||||
// systray.SetTemplateIcon(appTrayIcon)
|
||||
//} else {
|
||||
// // Support for light/dark mode icons
|
||||
// systray.SetDarkModeIcon(appTrayIcon)
|
||||
// systray.SetIcon(appTrayIcon)
|
||||
//}
|
||||
// Create a hidden window for the system tray menu to interact with
|
||||
trayWindow := d.core.App.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "System Tray Status",
|
||||
URL: "/#/system-tray",
|
||||
Width: 400,
|
||||
Frameless: true,
|
||||
Hidden: true,
|
||||
})
|
||||
systray.AttachWindow(trayWindow).WindowOffset(5)
|
||||
|
||||
// --- Build Tray Menu ---
|
||||
trayMenu := d.core.App.Menu.New()
|
||||
trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) {
|
||||
for _, window := range d.core.App.Window.GetAll() {
|
||||
window.Show()
|
||||
}
|
||||
})
|
||||
trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) {
|
||||
for _, window := range d.core.App.Window.GetAll() {
|
||||
window.Hide()
|
||||
}
|
||||
})
|
||||
|
||||
trayMenu.Add("Environment Info").OnClick(func(ctx *application.Context) {
|
||||
d.ShowEnvironmentDialog()
|
||||
})
|
||||
// Add brand-specific menu items
|
||||
//switch d.brand {
|
||||
//case AdminHub:
|
||||
// trayMenu.Add("Manage Workspace").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
//case ServerHub:
|
||||
// trayMenu.Add("Server Control").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
//case GatewayHub:
|
||||
// trayMenu.Add("Routing Table").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
//case DeveloperHub:
|
||||
// trayMenu.Add("Debug Console").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
//case ClientHub:
|
||||
// trayMenu.Add("Connect").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
// trayMenu.Add("Disconnect").OnClick(func(ctx *application.Context) { /* TODO */ })
|
||||
//}
|
||||
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||
d.core.App.Quit()
|
||||
})
|
||||
|
||||
systray.SetMenu(trayMenu)
|
||||
}
|
||||
28
display/window.go
Normal file
28
display/window.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package display
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
// OpenWindow creates and shows a new webview window.
|
||||
// This function is callable from the frontend.
|
||||
func (d *API) OpenWindow(name string, options application.WebviewWindowOptions) {
|
||||
// Check if a window with that name already exists
|
||||
if window, exists := d.core.App.Window.GetByName(name); exists {
|
||||
window.Focus()
|
||||
return
|
||||
}
|
||||
|
||||
window := d.core.App.Window.NewWithOptions(options)
|
||||
d.windowHandles[name] = window
|
||||
window.Show()
|
||||
}
|
||||
|
||||
// SelectDirectory opens a directory selection dialog and returns the selected path.
|
||||
func (d *API) SelectDirectory() (string, error) {
|
||||
dialog := application.OpenFileDialog()
|
||||
dialog.SetTitle("Select Project Directory")
|
||||
if path, err := dialog.PromptForSingleSelection(); err == nil {
|
||||
// Use selected directory path
|
||||
return path, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
27
docs/docs.go
Normal file
27
docs/docs.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/Snider/Core/display"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// displayer is an interface that defines the functionality docs needs from a display service.
|
||||
// This avoids a direct dependency on the display package or the core package.
|
||||
type displayer interface {
|
||||
OpenWindow(name string, options application.WebviewWindowOptions) (*application.WebviewWindow, error)
|
||||
}
|
||||
|
||||
// Service manages the documentation display and serving of assets.
|
||||
type Service struct {
|
||||
// --- Injected Dependencies ---
|
||||
app *application.App
|
||||
displayService *display.Display // Depends on the local interface, not a concrete type from another package.
|
||||
|
||||
// --- Internal State ---
|
||||
assets embed.FS
|
||||
}
|
||||
|
||||
//go:embed all:static/**/*
|
||||
var docsStatic embed.FS
|
||||
54
docs/service.go
Normal file
54
docs/service.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Snider/Core/display"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// NewService creates a new, un-wired documentation service.
|
||||
func NewService(assets embed.FS) *Service {
|
||||
return &Service{
|
||||
assets: assets,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup injects the required dependencies into the service.
|
||||
func (s *Service) Setup(app *application.App, displayService *display.Display) {
|
||||
s.app = app
|
||||
s.displayService = displayService
|
||||
}
|
||||
|
||||
// OpenDocsWindow opens a new window with the documentation.
|
||||
func (s *Service) OpenDocsWindow(path ...string) {
|
||||
url := "/docs/"
|
||||
if len(path) > 0 {
|
||||
fullPath := path[0]
|
||||
if strings.Contains(fullPath, "#") {
|
||||
parts := strings.SplitN(fullPath, "#", 2)
|
||||
pagePath := parts[0]
|
||||
fragment := parts[1]
|
||||
url += pagePath + "/#" + fragment
|
||||
} else {
|
||||
url += fullPath
|
||||
}
|
||||
}
|
||||
|
||||
// Use the injected displayService, which satisfies the local displayer interface.
|
||||
s.displayService.OpenWindow("docs", application.WebviewWindowOptions{
|
||||
Title: "Lethean Documentation",
|
||||
Height: 600,
|
||||
Width: 1000,
|
||||
URL: url,
|
||||
AlwaysOnTop: true,
|
||||
Frameless: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeHTTP serves the embedded documentation assets.
|
||||
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.FileServerFS(docsStatic).ServeHTTP(w, r)
|
||||
}
|
||||
0
docs/static/assets/style.css
vendored
Normal file
0
docs/static/assets/style.css
vendored
Normal file
0
docs/static/index.html
vendored
Normal file
0
docs/static/index.html
vendored
Normal file
45
filesystem/client.go
Normal file
45
filesystem/client.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package filesystem
|
||||
|
||||
import (
|
||||
"github.com/Snider/Core/filesystem/sftp"
|
||||
"github.com/Snider/Core/filesystem/webdav"
|
||||
)
|
||||
|
||||
// NewSFTPMedium creates and returns a new SFTP medium.
|
||||
func NewSFTPMedium(cfg sftp.ConnectionConfig) (Medium, error) {
|
||||
return sftp.New(cfg)
|
||||
}
|
||||
|
||||
// NewWebDAVMedium creates and returns a new WebDAV medium.
|
||||
func NewWebDAVMedium(cfg webdav.ConnectionConfig) (Medium, error) {
|
||||
return webdav.New(cfg)
|
||||
}
|
||||
|
||||
// Read retrieves the content of a file from the given medium.
|
||||
func Read(m Medium, path string) (string, error) {
|
||||
return m.Read(path)
|
||||
}
|
||||
|
||||
// Write saves content to a file on the given medium.
|
||||
func Write(m Medium, path, content string) error {
|
||||
return m.Write(path, content)
|
||||
}
|
||||
|
||||
// EnsureDir ensures a directory exists on the given medium.
|
||||
func EnsureDir(m Medium, path string) error {
|
||||
return m.EnsureDir(path)
|
||||
}
|
||||
|
||||
// IsFile checks if a path is a file on the given medium.
|
||||
func IsFile(m Medium, path string) bool {
|
||||
return m.IsFile(path)
|
||||
}
|
||||
|
||||
// Copy copies a file from a source medium to a destination medium.
|
||||
func Copy(sourceMedium Medium, sourcePath string, destMedium Medium, destPath string) error {
|
||||
content, err := sourceMedium.Read(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return destMedium.Write(destPath, content)
|
||||
}
|
||||
31
filesystem/client_test.go
Normal file
31
filesystem/client_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package filesystem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
m := NewMockMedium()
|
||||
m.Files["test.txt"] = "hello"
|
||||
content, err := Read(m, "test.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello", content)
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
m := NewMockMedium()
|
||||
err := Write(m, "test.txt", "hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello", m.Files["test.txt"])
|
||||
}
|
||||
|
||||
func TestCopy(t *testing.T) {
|
||||
source := NewMockMedium()
|
||||
dest := NewMockMedium()
|
||||
source.Files["test.txt"] = "hello"
|
||||
err := Copy(source, "test.txt", dest, "test.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello", dest.Files["test.txt"])
|
||||
}
|
||||
27
filesystem/filesystem.go
Normal file
27
filesystem/filesystem.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package filesystem
|
||||
|
||||
// Medium defines the standard interface for a storage backend.
|
||||
// This allows for different implementations (e.g., local disk, S3, SFTP)
|
||||
// to be used interchangeably.
|
||||
type Medium interface {
|
||||
// Read retrieves the content of a file as a string.
|
||||
Read(path string) (string, error)
|
||||
|
||||
// Write saves the given content to a file, overwriting it if it exists.
|
||||
Write(path, content string) error
|
||||
|
||||
// EnsureDir makes sure a directory exists, creating it if necessary.
|
||||
EnsureDir(path string) error
|
||||
|
||||
// IsFile checks if a path exists and is a regular file.
|
||||
IsFile(path string) bool
|
||||
|
||||
// FileGet is a convenience function that reads a file from the medium.
|
||||
FileGet(path string) (string, error)
|
||||
|
||||
// FileSet is a convenience function that writes a file to the medium.
|
||||
FileSet(path, content string) error
|
||||
}
|
||||
|
||||
// Pre-initialized, sandboxed medium for the local filesystem.
|
||||
var Local Medium
|
||||
1
filesystem/filesystem_test.go
Normal file
1
filesystem/filesystem_test.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package filesystem
|
||||
83
filesystem/local/client.go
Normal file
83
filesystem/local/client.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New creates a new instance of the local storage medium.
|
||||
// It requires a root path to sandbox all file operations.
|
||||
func New(rootPath string) (*Medium, error) {
|
||||
if err := os.MkdirAll(rootPath, os.ModePerm); err != nil {
|
||||
return nil, fmt.Errorf("could not create root directory at %s: %w", rootPath, err)
|
||||
}
|
||||
return &Medium{root: rootPath}, nil
|
||||
}
|
||||
|
||||
// path returns a full, safe path within the medium's root.
|
||||
func (m *Medium) path(subpath string) (string, error) {
|
||||
if strings.Contains(subpath, "..") {
|
||||
return "", fmt.Errorf("path traversal attempt detected")
|
||||
}
|
||||
return filepath.Join(m.root, subpath), nil
|
||||
}
|
||||
|
||||
// Read retrieves the content of a file from the local disk.
|
||||
func (m *Medium) Read(path string) (string, error) {
|
||||
safePath, err := m.path(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Write saves the given content to a file on the local disk.
|
||||
func (m *Medium) Write(path, content string) error {
|
||||
safePath, err := m.path(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(safePath)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(safePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// EnsureDir makes sure a directory exists on the local disk.
|
||||
func (m *Medium) EnsureDir(path string) error {
|
||||
safePath, err := m.path(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(safePath, os.ModePerm)
|
||||
}
|
||||
|
||||
// IsFile checks if a path exists and is a regular file on the local disk.
|
||||
func (m *Medium) IsFile(path string) bool {
|
||||
safePath, err := m.path(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(safePath)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// FileGet is a convenience function that reads a file from the medium.
|
||||
func (m *Medium) FileGet(path string) (string, error) {
|
||||
return m.Read(path)
|
||||
}
|
||||
|
||||
// FileSet is a convenience function that writes a file to the medium.
|
||||
func (m *Medium) FileSet(path, content string) error {
|
||||
return m.Write(path, content)
|
||||
}
|
||||
154
filesystem/local/client_test.go
Normal file
154
filesystem/local/client_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
testRoot, err := os.MkdirTemp("", "local_test_root")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(testRoot) // Clean up after the test
|
||||
|
||||
// Test successful creation
|
||||
medium, err := New(testRoot)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, medium)
|
||||
assert.Equal(t, testRoot, medium.root)
|
||||
|
||||
// Verify the root directory exists
|
||||
info, err := os.Stat(testRoot)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
|
||||
// Test creating a new instance with an existing directory (should not error)
|
||||
medium2, err := New(testRoot)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, medium2)
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
testRoot := "/tmp/test_root"
|
||||
medium := &Medium{root: testRoot}
|
||||
|
||||
// Valid path
|
||||
validPath, err := medium.path("file.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath)
|
||||
|
||||
// Subdirectory path
|
||||
subDirPath, err := medium.path("dir/sub/file.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath)
|
||||
|
||||
// Path traversal attempt
|
||||
_, err = medium.path("../secret.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
||||
|
||||
_, err = medium.path("dir/../../secret.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
||||
}
|
||||
|
||||
func TestReadWrite(t *testing.T) {
|
||||
testRoot, err := os.MkdirTemp("", "local_read_write_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(testRoot)
|
||||
|
||||
medium, err := New(testRoot)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileName := "testfile.txt"
|
||||
filePath := filepath.Join("subdir", fileName)
|
||||
content := "Hello, Gopher!\nThis is a test file."
|
||||
|
||||
// Test Write
|
||||
err = medium.Write(filePath, content)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify file content by reading directly from OS
|
||||
readContent, err := os.ReadFile(filepath.Join(testRoot, filePath))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, string(readContent))
|
||||
|
||||
// Test Read
|
||||
readByMedium, err := medium.Read(filePath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, readByMedium)
|
||||
|
||||
// Test Read non-existent file
|
||||
_, err = medium.Read("nonexistent.txt")
|
||||
assert.Error(t, err)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Test Write to a path with traversal attempt
|
||||
writeErr := medium.Write("../badfile.txt", "malicious content")
|
||||
assert.Error(t, writeErr)
|
||||
assert.Contains(t, writeErr.Error(), "path traversal attempt detected")
|
||||
}
|
||||
|
||||
func TestEnsureDir(t *testing.T) {
|
||||
testRoot, err := os.MkdirTemp("", "local_ensure_dir_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(testRoot)
|
||||
|
||||
medium, err := New(testRoot)
|
||||
assert.NoError(t, err)
|
||||
|
||||
dirName := "newdir/subdir"
|
||||
dirPath := filepath.Join(testRoot, dirName)
|
||||
|
||||
// Test creating a new directory
|
||||
err = medium.EnsureDir(dirName)
|
||||
assert.NoError(t, err)
|
||||
info, err := os.Stat(dirPath)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
|
||||
// Test ensuring an existing directory (should not error)
|
||||
err = medium.EnsureDir(dirName)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test ensuring a directory with path traversal attempt
|
||||
err = medium.EnsureDir("../bad_dir")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
||||
}
|
||||
|
||||
func TestIsFile(t *testing.T) {
|
||||
testRoot, err := os.MkdirTemp("", "local_is_file_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(testRoot)
|
||||
|
||||
medium, err := New(testRoot)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a test file
|
||||
fileName := "existing_file.txt"
|
||||
filePath := filepath.Join(testRoot, fileName)
|
||||
err = os.WriteFile(filePath, []byte("content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a test directory
|
||||
dirName := "existing_dir"
|
||||
dirPath := filepath.Join(testRoot, dirName)
|
||||
err = os.Mkdir(dirPath, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with an existing file
|
||||
assert.True(t, medium.IsFile(fileName))
|
||||
|
||||
// Test with a non-existent file
|
||||
assert.False(t, medium.IsFile("nonexistent_file.txt"))
|
||||
|
||||
// Test with a directory
|
||||
assert.False(t, medium.IsFile(dirName))
|
||||
|
||||
// Test with path traversal attempt
|
||||
assert.False(t, medium.IsFile("../bad_file.txt"))
|
||||
}
|
||||
6
filesystem/local/local.go
Normal file
6
filesystem/local/local.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package local
|
||||
|
||||
// Medium implements the filesystem.Medium interface for the local disk.
|
||||
type Medium struct {
|
||||
root string
|
||||
}
|
||||
47
filesystem/mock.go
Normal file
47
filesystem/mock.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package filesystem
|
||||
|
||||
import "github.com/stretchr/testify/assert"
|
||||
|
||||
// MockMedium implements the Medium interface for testing purposes.
|
||||
type MockMedium struct {
|
||||
Files map[string]string
|
||||
Dirs map[string]bool
|
||||
}
|
||||
|
||||
func NewMockMedium() *MockMedium {
|
||||
return &MockMedium{
|
||||
Files: make(map[string]string),
|
||||
Dirs: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMedium) Read(path string) (string, error) {
|
||||
content, ok := m.Files[path]
|
||||
if !ok {
|
||||
return "", assert.AnError // Simulate file not found error
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) Write(path, content string) error {
|
||||
m.Files[path] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) EnsureDir(path string) error {
|
||||
m.Dirs[path] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) IsFile(path string) bool {
|
||||
_, ok := m.Files[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *MockMedium) FileGet(path string) (string, error) {
|
||||
return m.Read(path)
|
||||
}
|
||||
|
||||
func (m *MockMedium) FileSet(path, content string) error {
|
||||
return m.Write(path, content)
|
||||
}
|
||||
125
filesystem/sftp/client.go
Normal file
125
filesystem/sftp/client.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package sftp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/skeema/knownhosts"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// New creates a new, connected instance of the SFTP storage medium.
|
||||
func New(cfg ConnectionConfig) (*Medium, error) {
|
||||
var authMethods []ssh.AuthMethod
|
||||
|
||||
if cfg.KeyFile != "" {
|
||||
key, err := os.ReadFile(cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read private key: %w", err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse private key: %w", err)
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
} else if cfg.Password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(cfg.Password))
|
||||
} else {
|
||||
return nil, fmt.Errorf("no authentication method provided (password or keyfile)")
|
||||
}
|
||||
|
||||
kh, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read known_hosts: %w", err)
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: kh.HostKeyCallback(),
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
conn, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to dial ssh: %w", err)
|
||||
}
|
||||
|
||||
sftpClient, err := sftp.NewClient(conn)
|
||||
if err != nil {
|
||||
// Ensure the underlying ssh connection is closed on failure
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to create sftp client: %w", err)
|
||||
}
|
||||
|
||||
return &Medium{client: sftpClient}, nil
|
||||
}
|
||||
|
||||
// Read retrieves the content of a file from the SFTP server.
|
||||
func (m *Medium) Read(path string) (string, error) {
|
||||
file, err := m.client.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sftp: failed to open file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sftp: failed to read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Write saves the given content to a file on the SFTP server.
|
||||
func (m *Medium) Write(path, content string) error {
|
||||
// Ensure the remote directory exists first.
|
||||
dir := filepath.Dir(path)
|
||||
if err := m.EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := m.client.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftp: failed to create file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.Write([]byte(content)); err != nil {
|
||||
return fmt.Errorf("sftp: failed to write to file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDir makes sure a directory exists on the SFTP server.
|
||||
func (m *Medium) EnsureDir(path string) error {
|
||||
// MkdirAll is idempotent, so it won't error if the path already exists.
|
||||
return m.client.MkdirAll(path)
|
||||
}
|
||||
|
||||
// IsFile checks if a path exists and is a regular file on the SFTP server.
|
||||
func (m *Medium) IsFile(path string) bool {
|
||||
info, err := m.client.Stat(path)
|
||||
if err != nil {
|
||||
// If the error is "not found", it's definitely not a file.
|
||||
// For any other error, we also conservatively say it's not a file.
|
||||
return false
|
||||
}
|
||||
// Return true only if it's not a directory.
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// FileGet is a convenience function that reads a file from the medium.
|
||||
func (m *Medium) FileGet(path string) (string, error) {
|
||||
return m.Read(path)
|
||||
}
|
||||
|
||||
// FileSet is a convenience function that writes a file to the medium.
|
||||
func (m *Medium) FileSet(path, content string) error {
|
||||
return m.Write(path, content)
|
||||
}
|
||||
19
filesystem/sftp/sftp.go
Normal file
19
filesystem/sftp/sftp.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package sftp
|
||||
|
||||
import (
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// Medium implements the filesystem.Medium interface for the SFTP protocol.
|
||||
type Medium struct {
|
||||
client *sftp.Client
|
||||
}
|
||||
|
||||
// ConnectionConfig holds the necessary details to connect to an SFTP server.
|
||||
type ConnectionConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string // For password-based auth
|
||||
KeyFile string // Path to a private key for key-based auth
|
||||
}
|
||||
16
filesystem/webdav/client.go
Normal file
16
filesystem/webdav/client.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package webdav
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Medium implements the filesystem.Medium interface for the WebDAV protocol.
|
||||
type Medium struct {
|
||||
client *http.Client
|
||||
baseURL string // e.g., https://dav.example.com/remote.php/dav/files/username/
|
||||
}
|
||||
|
||||
// ConnectionConfig holds the necessary details to connect to a WebDAV server.
|
||||
type ConnectionConfig struct {
|
||||
URL string // The full base URL of the WebDAV share.
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
183
filesystem/webdav/webdav.go
Normal file
183
filesystem/webdav/webdav.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package webdav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New creates a new, connected instance of the WebDAV storage medium.
|
||||
func New(cfg ConnectionConfig) (*Medium, error) {
|
||||
transport := &authTransport{
|
||||
Username: cfg.User,
|
||||
Password: cfg.Password,
|
||||
Wrapped: http.DefaultTransport,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Transport: transport}
|
||||
|
||||
// Ping the server to ensure the connection and credentials are valid.
|
||||
// We do a PROPFIND on the root, which is a standard WebDAV operation.
|
||||
req, err := http.NewRequest("PROPFIND", cfg.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webdav: failed to create ping request: %w", err)
|
||||
}
|
||||
req.Header.Set("Depth", "0")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webdav: connection test failed: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status)
|
||||
}
|
||||
|
||||
return &Medium{
|
||||
client: httpClient,
|
||||
baseURL: cfg.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Read retrieves the content of a file from the WebDAV server.
|
||||
func (m *Medium) Read(p string) (string, error) {
|
||||
url := m.resolveURL(p)
|
||||
resp, err := m.client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Write saves the given content to a file on the WebDAV server.
|
||||
func (m *Medium) Write(p, content string) error {
|
||||
// Ensure the parent directory exists first.
|
||||
dir := path.Dir(p)
|
||||
if dir != "." && dir != "/" {
|
||||
if err := m.EnsureDir(dir); err != nil {
|
||||
return err // This will be a detailed error from EnsureDir
|
||||
}
|
||||
}
|
||||
|
||||
url := m.resolveURL(p)
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("webdav: failed to create PUT request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// StatusCreated (201) or StatusNoContent (204) are success codes for PUT.
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed.
|
||||
func (m *Medium) EnsureDir(p string) error {
|
||||
// To mimic MkdirAll, we create each part of the path sequentially.
|
||||
parts := strings.Split(p, "/")
|
||||
currentPath := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
currentPath = path.Join(currentPath, part)
|
||||
url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash
|
||||
|
||||
req, err := http.NewRequest("MKCOL", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err)
|
||||
}
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 405 Method Not Allowed means it already exists, which is fine for us.
|
||||
// 201 Created is a success.
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile checks if a path exists and is a regular file on the WebDAV server.
|
||||
func (m *Medium) IsFile(p string) bool {
|
||||
url := m.resolveURL(p)
|
||||
req, err := http.NewRequest("PROPFIND", url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Depth", "0")
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// If we get anything other than a Multi-Status, it's probably not a file.
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return false
|
||||
}
|
||||
|
||||
// A simple check: if the response body contains the string for a collection, it's a directory.
|
||||
// A more robust implementation would parse the XML response.
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return !strings.Contains(string(body), "<D:collection/>")
|
||||
}
|
||||
|
||||
// resolveURL joins the base URL with a path segment, ensuring correct slashes.
|
||||
func (m *Medium) resolveURL(p string) string {
|
||||
return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/")
|
||||
}
|
||||
|
||||
// authTransport is a custom http.RoundTripper to inject Basic Auth.
|
||||
type authTransport struct {
|
||||
Username string
|
||||
Password string
|
||||
Wrapped http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.SetBasicAuth(t.Username, t.Password)
|
||||
return t.Wrapped.RoundTrip(req)
|
||||
}
|
||||
|
||||
// FileGet is a convenience function that reads a file from the medium.
|
||||
func (m *Medium) FileGet(path string) (string, error) {
|
||||
return m.Read(path)
|
||||
}
|
||||
|
||||
// FileSet is a convenience function that writes a file to the medium.
|
||||
func (m *Medium) FileSet(path, content string) error {
|
||||
return m.Write(path, content)
|
||||
}
|
||||
56
go.mod
Normal file
56
go.mod
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
module github.com/Snider/Core
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/skeema/knownhosts v1.3.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.36
|
||||
golang.org/x/crypto v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.13.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lmittmann/tint v1.0.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
45
header.go
Normal file
45
header.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"sync"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
type Contract struct {
|
||||
DontPanic bool
|
||||
DisableLogging bool
|
||||
}
|
||||
type Ipc struct {
|
||||
Target string
|
||||
}
|
||||
|
||||
var allowedModules = map[string]bool{
|
||||
"docs": true,
|
||||
"display": true,
|
||||
// add more names here if you want to restrict what can be loaded
|
||||
}
|
||||
|
||||
type Message interface{}
|
||||
type Core struct {
|
||||
once sync.Once
|
||||
initErr error
|
||||
App *application.App
|
||||
assets embed.FS
|
||||
|
||||
modMu sync.RWMutex
|
||||
mods map[string]any
|
||||
ipcMu sync.RWMutex
|
||||
ipcHandlers []func(*Core, Message) error
|
||||
serviceLock bool
|
||||
servicesLocked bool
|
||||
}
|
||||
|
||||
type Option func(*Core) error
|
||||
|
||||
var (
|
||||
instance *Core
|
||||
once sync.Once
|
||||
initErr error
|
||||
)
|
||||
41
workspace/local.go
Normal file
41
workspace/local.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package workspace
|
||||
|
||||
import "github.com/Snider/Core/filesystem"
|
||||
|
||||
// localMedium implements the Medium interface for the local disk.
|
||||
type localMedium struct{}
|
||||
|
||||
// NewLocalMedium creates a new instance of the local storage medium.
|
||||
func NewLocalMedium() filesystem.Medium {
|
||||
return &localMedium{}
|
||||
}
|
||||
|
||||
// FileGet reads a file from the local disk.
|
||||
func (m *localMedium) FileGet(path string) (string, error) {
|
||||
return filesystem.Read(filesystem.Local, path)
|
||||
}
|
||||
|
||||
// FileSet writes a file to the local disk.
|
||||
func (m *localMedium) FileSet(path, content string) error {
|
||||
return filesystem.Write(filesystem.Local, path, content)
|
||||
}
|
||||
|
||||
// Read reads a file from the local disk.
|
||||
func (m *localMedium) Read(path string) (string, error) {
|
||||
return filesystem.Read(filesystem.Local, path)
|
||||
}
|
||||
|
||||
// Write writes a file to the local disk.
|
||||
func (m *localMedium) Write(path, content string) error {
|
||||
return filesystem.Write(filesystem.Local, path, content)
|
||||
}
|
||||
|
||||
// EnsureDir creates a directory on the local disk.
|
||||
func (m *localMedium) EnsureDir(path string) error {
|
||||
return filesystem.EnsureDir(filesystem.Local, path)
|
||||
}
|
||||
|
||||
// IsFile checks if a path exists and is a file on the local disk.
|
||||
func (m *localMedium) IsFile(path string) bool {
|
||||
return filesystem.IsFile(filesystem.Local, path)
|
||||
}
|
||||
124
workspace/service.go
Normal file
124
workspace/service.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Snider/Core/config"
|
||||
"github.com/Snider/Core/crypt/lib/lthn"
|
||||
"github.com/Snider/Core/crypt/lib/openpgp"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
)
|
||||
|
||||
// NewService creates a new WorkspaceService.
|
||||
func NewService(cfg *config.Config, medium filesystem.Medium) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
workspaceList: make(map[string]string),
|
||||
medium: medium,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup Startup initializes the service, loading the workspace list.
|
||||
func (s *Service) ServiceStartup() error {
|
||||
listPath := filepath.Join(s.config.WorkspacesDir, listFile)
|
||||
|
||||
if s.medium.IsFile(listPath) {
|
||||
content, err := s.medium.FileGet(listPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read workspace list: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil {
|
||||
fmt.Printf("Warning: could not parse workspace list: %v\n", err)
|
||||
s.workspaceList = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
return s.SwitchWorkspace(defaultWorkspace)
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a new, obfuscated workspace on the local medium.
|
||||
func (s *Service) CreateWorkspace(identifier, password string) (string, error) {
|
||||
realName := lthn.Hash(identifier)
|
||||
workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName))
|
||||
workspacePath := filepath.Join(s.config.WorkspacesDir, workspaceID)
|
||||
|
||||
if _, exists := s.workspaceList[workspaceID]; exists {
|
||||
return "", fmt.Errorf("workspace for this identifier already exists")
|
||||
}
|
||||
|
||||
dirsToCreate := []string{"config", "log", "data", "files", "keys"}
|
||||
for _, dir := range dirsToCreate {
|
||||
if err := s.medium.EnsureDir(filepath.Join(workspacePath, dir)); err != nil {
|
||||
return "", fmt.Errorf("failed to create workspace directory '%s': %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
keyPair, err := openpgp.CreateKeyPair(workspaceID, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create workspace key pair: %w", err)
|
||||
}
|
||||
|
||||
keyFiles := map[string]string{
|
||||
filepath.Join(workspacePath, "keys", "key.pub"): keyPair.PublicKey,
|
||||
filepath.Join(workspacePath, "keys", "key.priv"): keyPair.PrivateKey,
|
||||
}
|
||||
for path, content := range keyFiles {
|
||||
if err := s.medium.FileSet(path, content); err != nil {
|
||||
return "", fmt.Errorf("failed to write key file %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.workspaceList[workspaceID] = keyPair.PublicKey
|
||||
listData, err := json.MarshalIndent(s.workspaceList, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal workspace list: %w", err)
|
||||
}
|
||||
|
||||
listPath := filepath.Join(s.config.WorkspacesDir, listFile)
|
||||
if err := s.medium.FileSet(listPath, string(listData)); err != nil {
|
||||
return "", fmt.Errorf("failed to write workspace list file: %w", err)
|
||||
}
|
||||
|
||||
return workspaceID, nil
|
||||
}
|
||||
|
||||
// SwitchWorkspace changes the active workspace.
|
||||
func (s *Service) SwitchWorkspace(name string) error {
|
||||
if name != defaultWorkspace {
|
||||
if _, exists := s.workspaceList[name]; !exists {
|
||||
return fmt.Errorf("workspace '%s' does not exist", name)
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(s.config.WorkspacesDir, name)
|
||||
if err := s.medium.EnsureDir(path); err != nil {
|
||||
return fmt.Errorf("failed to ensure workspace directory exists: %w", err)
|
||||
}
|
||||
|
||||
s.activeWorkspace = &Workspace{
|
||||
Name: name,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkspaceFileGet retrieves a file from the active workspace.
|
||||
func (s *Service) WorkspaceFileGet(filename string) (string, error) {
|
||||
if s.activeWorkspace == nil {
|
||||
return "", fmt.Errorf("no active workspace")
|
||||
}
|
||||
path := filepath.Join(s.activeWorkspace.Path, filename)
|
||||
return s.medium.FileGet(path)
|
||||
}
|
||||
|
||||
// WorkspaceFileSet writes a file to the active workspace.
|
||||
func (s *Service) WorkspaceFileSet(filename, content string) error {
|
||||
if s.activeWorkspace == nil {
|
||||
return fmt.Errorf("no active workspace")
|
||||
}
|
||||
path := filepath.Join(s.activeWorkspace.Path, filename)
|
||||
return s.medium.FileSet(path, content)
|
||||
}
|
||||
25
workspace/workspace.go
Normal file
25
workspace/workspace.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"github.com/Snider/Core/config"
|
||||
"github.com/Snider/Core/filesystem"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorkspace = "default"
|
||||
listFile = "list.json"
|
||||
)
|
||||
|
||||
// Workspace represents a user's workspace.
|
||||
type Workspace struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Service manages user workspaces.
|
||||
type Service struct {
|
||||
config *config.Config
|
||||
activeWorkspace *Workspace
|
||||
workspaceList map[string]string // Maps Workspace ID to Public Key
|
||||
medium filesystem.Medium
|
||||
}
|
||||
157
workspace/workspace_test.go
Normal file
157
workspace/workspace_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"core/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// MockMedium implements the Medium interface for testing purposes.
|
||||
type MockMedium struct {
|
||||
Files map[string]string
|
||||
Dirs map[string]bool
|
||||
}
|
||||
|
||||
func NewMockMedium() *MockMedium {
|
||||
return &MockMedium{
|
||||
Files: make(map[string]string),
|
||||
Dirs: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMedium) FileGet(path string) (string, error) {
|
||||
content, ok := m.Files[path]
|
||||
if !ok {
|
||||
return "", assert.AnError // Simulate file not found error
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) FileSet(path, content string) error {
|
||||
m.Files[path] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) EnsureDir(path string) error {
|
||||
m.Dirs[path] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMedium) IsFile(path string) bool {
|
||||
_, ok := m.Files[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *MockMedium) Read(path string) (string, error) {
|
||||
return m.FileGet(path)
|
||||
}
|
||||
|
||||
func (m *MockMedium) Write(path, content string) error {
|
||||
return m.FileSet(path, content)
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
mockConfig := &config.Config{} // You might want to mock this further if its behavior is critical
|
||||
mockMedium := NewMockMedium()
|
||||
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, mockConfig, service.config)
|
||||
assert.Equal(t, mockMedium, service.medium)
|
||||
assert.NotNil(t, service.workspaceList)
|
||||
assert.Nil(t, service.activeWorkspace) // Initially no active workspace
|
||||
}
|
||||
|
||||
func TestServiceStartup(t *testing.T) {
|
||||
mockConfig := &config.Config{
|
||||
WorkspacesDir: "/tmp/workspaces",
|
||||
}
|
||||
|
||||
// Test case 1: list.json exists and is valid
|
||||
t.Run("existing valid list.json", func(t *testing.T) {
|
||||
mockMedium := NewMockMedium()
|
||||
|
||||
// Prepare a mock workspace list
|
||||
expectedWorkspaceList := map[string]string{
|
||||
"workspace1": "pubkey1",
|
||||
"workspace2": "pubkey2",
|
||||
}
|
||||
listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ")
|
||||
|
||||
listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
|
||||
mockMedium.FileSet(listPath, string(listContent))
|
||||
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
err := service.ServiceStartup()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedWorkspaceList, service.workspaceList)
|
||||
assert.NotNil(t, service.activeWorkspace)
|
||||
assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
|
||||
assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
|
||||
})
|
||||
|
||||
// Test case 2: list.json does not exist
|
||||
t.Run("no list.json", func(t *testing.T) {
|
||||
mockMedium := NewMockMedium() // Fresh medium with no files
|
||||
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
err := service.ServiceStartup()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, service.workspaceList)
|
||||
assert.Empty(t, service.workspaceList) // Should be empty if no list.json
|
||||
assert.NotNil(t, service.activeWorkspace)
|
||||
assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
|
||||
assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
|
||||
})
|
||||
|
||||
// Test case 3: list.json exists but is invalid
|
||||
t.Run("invalid list.json", func(t *testing.T) {
|
||||
mockMedium := NewMockMedium()
|
||||
|
||||
listPath := filepath.Join(mockConfig.WorkspacesDir, listFile)
|
||||
mockMedium.FileSet(listPath, "{invalid json") // Invalid JSON
|
||||
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
err := service.ServiceStartup()
|
||||
|
||||
assert.NoError(t, err) // Error is logged, but startup continues
|
||||
assert.NotNil(t, service.workspaceList)
|
||||
assert.Empty(t, service.workspaceList) // Should be empty if invalid list.json
|
||||
assert.NotNil(t, service.activeWorkspace)
|
||||
assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name)
|
||||
assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateWorkspace(t *testing.T) {
|
||||
mockConfig := &config.Config{
|
||||
WorkspacesDir: "/tmp/workspaces",
|
||||
}
|
||||
mockMedium := NewMockMedium()
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
|
||||
workspaceID, err := service.CreateWorkspace("test", "password")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, workspaceID)
|
||||
}
|
||||
|
||||
func TestSwitchWorkspace(t *testing.T) {
|
||||
mockConfig := &config.Config{
|
||||
WorkspacesDir: "/tmp/workspaces",
|
||||
}
|
||||
mockMedium := NewMockMedium()
|
||||
service := NewService(mockConfig, mockMedium)
|
||||
|
||||
workspaceID, err := service.CreateWorkspace("test", "password")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = service.SwitchWorkspace(workspaceID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, workspaceID, service.activeWorkspace.Name)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue