Compare commits

...

14 Commits

Author SHA1 Message Date
Scai 8906504a0c
Merge 57fbee1490 into 5e91cfc7fa 2025-02-18 22:49:57 +01:00
Adam Shiervani 5e91cfc7fa
Add full dark mode support to FieldLabel component (#173) 2025-02-18 19:30:04 +01:00
Adam Shiervani 9ffdf0c4a6
feat(dev): use npx and update dev_device.sh to accept IP address as a command-line argument (#169) 2025-02-18 17:22:35 +01:00
Aveline 591d512b11
add extra logging and tune timeout settings for cloud (#167)
* chore(config): merge userConfig with defaultConfig and add a lock

* chore(cloud): add extra logging and tune timeout settings
2025-02-18 17:22:03 +01:00
Scai 57fbee1490 chore: remove unnecessary actions 2025-01-08 00:51:44 +00:00
Scai 0e65c0a9a9 chore: add last go version to matrix 2025-01-08 00:41:33 +00:00
Scai 2dafb5c9c1 chore: update existing comment 2025-01-08 00:37:19 +00:00
Scai 566305549f chore: fix table comment 2025-01-08 00:34:49 +00:00
Scai 1505c37e4c chore: comment fix issues 2025-01-08 00:29:04 +00:00
Scai 564eee9b00 chore: change to artifact 2025-01-08 00:19:52 +00:00
Scai fab575dbe0 chore: permissions update on push 2025-01-08 00:17:38 +00:00
Scai 97958e7b86 chore: push workflow 2025-01-08 00:13:52 +00:00
Scai 2f7042df18 chore: increase permissions to content workflow 2025-01-07 22:58:23 +00:00
Scai 2cadda4e00 chore: create release workflow 2025-01-07 22:40:03 +00:00
8 changed files with 285 additions and 33 deletions

126
.github/workflows/push.yaml vendored Normal file
View File

@ -0,0 +1,126 @@
name: Push
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
build:
name: Build
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04]
go: [1.21, 1.23.4]
node: [21]
goos: [linux]
goarch: [arm]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install Dependencies
working-directory: ui
run: npm ci
- name: Build UI
working-directory: ui
run: npm run build:device
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Install Go Dependencies
run: |
go mod download
- name: Build Binaries
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=dev-${GIT_COMMIT:0:7}" -o bin/jetkvm_app cmd/main.go
chmod 755 bin/jetkvm_app
- name: Upload Debug Artifact
uses: actions/upload-artifact@v4
if: ${{ (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') && matrix.go == '1.21' }}
with:
name: jetkvm_app_debug
path: bin/jetkvm_app
comment:
name: Comment
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate Links
id: linksa
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ARTIFACT_ID=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --jq '.artifacts[0].id')
echo "ARTIFACT_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/$ARTIFACT_ID" >> $GITHUB_ENV
echo "LATEST_COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Comment on PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TITLE="${{ github.event.pull_request.title }}"
PR_NUMBER=${{ github.event.pull_request.number }}
else
TITLE="main branch"
fi
COMMENT=$(cat << EOF
✅ **Build successfully for $TITLE!**
| Name | Link |
|------------------|----------------------------------------------------------------------|
| 🔗 Debug Binary | [Download](${{ env.ARTIFACT_URL }}) |
| 🔗 Latest commit | [${{ env.LATEST_COMMIT }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}) |
EOF
)
# Post Comment
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Look for an existing comment
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments \
--jq '.[] | select(.body | contains("✅ **Build successfully for")) | .id')
if [ -z "$COMMENT_ID" ]; then
# Create a new comment if none exists
gh pr comment $PR_NUMBER --body "$COMMENT"
else
# Update the existing comment
gh api repos/${{ github.repository }}/issues/comments/$COMMENT_ID \
--method PATCH \
-f body="$COMMENT"
fi
else
# Log the comment for main branch
echo "$COMMENT"
fi

91
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,91 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
name: Release
runs-on: ubuntu-22.04
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 21
- name: Install Dependencies
working-directory: ui
run: npm ci
- name: Build UI
working-directory: ui
run: npm run build:device
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.21
- name: Build Release Binaries
env:
REF: ${{ github.ref }}
run: |
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=${REF:11}" -o bin/jetkvm_app cmd/main.go
chmod 755 bin/jetkvm_app
- name: Create checksum
env:
REF: ${{ github.ref }}
run: |
SUM=$(shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1)
echo -e "\n#### SHA256 Checksum\n\`\`\`\n$SUM bin/jetkvm_app\n\`\`\`\n" >> ./RELEASE_CHANGELOG
echo -e "$SUM bin/jetkvm_app\n" > checksums.txt
- name: Create Release Branch
env:
REF: ${{ github.ref }}
run: |
BRANCH=release/${REF:10}
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git checkout -b ${BRANCH}
git push -u origin ${BRANCH}
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: true
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
body_path: ./RELEASE_CHANGELOG
- name: Upload JetKVM binary
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: bin/jetkvm_app
asset_name: jetkvm_app
asset_content_type: application/octet-stream
- name: Upload checksum
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./checksums.txt
asset_name: checksums.txt
asset_content_type: text/plain

View File

@ -24,6 +24,18 @@ type CloudRegisterRequest struct {
ClientId string `json:"clientId"`
}
const (
// CloudWebSocketConnectTimeout is the timeout for the websocket connection to the cloud
CloudWebSocketConnectTimeout = 1 * time.Minute
// CloudAPIRequestTimeout is the timeout for cloud API requests
CloudAPIRequestTimeout = 10 * time.Second
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second
)
func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest
@ -44,22 +56,31 @@ func handleCloudRegister(c *gin.Context) {
return
}
resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload))
client := &http.Client{Timeout: CloudAPIRequestTimeout}
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiResp, err := client.Do(apiReq)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
return
}
defer resp.Body.Close()
defer apiResp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status})
if apiResp.StatusCode != http.StatusOK {
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
return
}
var tokenResp struct {
SecretToken string `json:"secretToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil {
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
return
}
@ -70,7 +91,7 @@ func handleCloudRegister(c *gin.Context) {
}
if config.CloudToken == "" {
logger.Info("Starting websocket client due to adoption")
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
@ -122,7 +143,7 @@ func runWebsocketClient() error {
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
@ -131,15 +152,15 @@ func runWebsocketClient() error {
return err
}
defer c.CloseNow()
logger.Infof("WS connected to %v", wsURL.String())
cloudLogger.Infof("websocket connected to %s", wsURL.String())
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(15 * time.Second)
time.Sleep(CloudWebSocketPingInterval)
err := c.Ping(runCtx)
if err != nil {
logger.Warnf("websocket ping error: %v", err)
cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
@ -157,24 +178,30 @@ func runWebsocketClient() error {
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
logger.Warnf("unable to parse ws message: %v", string(msg))
cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
cloudLogger.Tracef("session request info: %v", req)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
logger.Infof("error starting new session: %v", err)
cloudLogger.Infof("error starting new session: %v", err)
continue
}
}
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute)
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
if err != nil {
fmt.Println("Failed to initialize OIDC provider:", err)
_ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Errorf("failed to initialize OIDC provider: %v", err)
return err
}
@ -190,6 +217,7 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
if config.GoogleIdentity != googleIdentity {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"})
return fmt.Errorf("google identity mismatch")
}
@ -216,6 +244,9 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
_ = peerConn.Close()
}()
}
cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session)
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
return nil
@ -225,7 +256,7 @@ func RunWebsocketClient() {
for {
err := runWebsocketClient()
if err != nil {
fmt.Println("Websocket client error:", err)
cloudLogger.Errorf("websocket client error: %v", err)
time.Sleep(5 * time.Second)
}
}
@ -254,7 +285,7 @@ func rpcDeregisterDevice() error {
}
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: 10 * time.Second}
client := &http.Client{Timeout: CloudAPIRequestTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send deregister request: %w", err)

View File

@ -47,6 +47,9 @@ var (
)
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil {
logger.Info("config already loaded, skipping")
return

View File

@ -58,6 +58,9 @@ make build_dev
# Change directory to the binary output directory
cd bin
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
@ -79,8 +82,7 @@ cd "$REMOTE_PATH"
chmod +x jetkvm_app_debug
# Run the application in the background
./jetkvm_app_debug
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
EOF
echo "Deployment complete."

1
log.go
View File

@ -6,3 +6,4 @@ import "github.com/pion/logging"
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")

View File

@ -1,21 +1,19 @@
#!/bin/bash
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
echo "Usage: $0 <JetKVM IP Address>"
exit 1
fi
ip_address="$1"
# Print header
echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │"
echo "└──────────────────────────────────────┘"
# Prompt for IP address
printf "Please enter the IP address of your JetKVM device: "
read ip_address
# Validate input is not empty
if [ -z "$ip_address" ]; then
echo "Error: IP address cannot be empty"
exit 1
fi
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="http://$ip_address" vite dev --mode=device
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device

View File

@ -26,7 +26,7 @@ export default function FieldLabel({
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description}
</span>
)}
@ -34,12 +34,12 @@ export default function FieldLabel({
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
<div className="flex select-none flex-col">
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description}
</span>
)}