Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Build Desktop App

on:
push:
tags:
- "desktop-v*"
workflow_dispatch:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux
- os: macos-latest
platform: mac
- os: windows-latest
platform: win

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install

- name: Build workspace packages
run: pnpm --filter @hackclub/lapse-shared --filter @hackclub/lapse-api run build

- name: Build desktop app
working-directory: apps/desktop
run: npx electron-vite build

- name: Get Electron version
id: electron-ver
shell: bash
run: echo "version=$(node -e "console.log(require('electron/package.json').version)")" >> $GITHUB_OUTPUT

- name: Build installer (Linux)
if: matrix.platform == 'linux'
working-directory: apps/desktop
run: npx electron-builder --linux AppImage deb --config electron-builder.yml -c.electronVersion=${{ steps.electron-ver.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Build installer (macOS)
if: matrix.platform == 'mac'
working-directory: apps/desktop
run: npx electron-builder --mac dmg zip --config electron-builder.yml -c.electronVersion=${{ steps.electron-ver.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Build installer (Windows)
if: matrix.platform == 'win'
working-directory: apps/desktop
run: npx electron-builder --win nsis --config electron-builder.yml -c.electronVersion=${{ steps.electron-ver.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.platform }}
path: |
apps/desktop/release/*.exe
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb
if-no-files-found: warn

release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write

steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node-linker=hoisted
3 changes: 3 additions & 0 deletions apps/desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
out/
release/
14 changes: 14 additions & 0 deletions apps/desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
Binary file added apps/desktop/build/icon.ico
Binary file not shown.
Binary file added apps/desktop/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/build/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
appId: com.hackclub.lapse
productName: Lapse
directories:
output: release
buildResources: build

files:
- "out/**/*"

mac:
category: public.app-category.developer-tools
icon: build/icon.png
target:
- target: dmg
arch: [x64, arm64]
- target: zip
arch: [x64, arm64]

win:
icon: build/icon.png
target:
- target: nsis
arch: [x64]

linux:
icon: build/icon.png
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]

nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true

publish:
provider: github
owner: claynicholson
repo: Clapse
46 changes: 46 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/postcss";
import { resolve } from "node:path";

export default defineConfig({
main: {
resolve: {
alias: {
"@": resolve(__dirname, "src")
}
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "src/main/index.ts"),
captureWindow: resolve(__dirname, "src/main/capture/captureWindow.ts")
}
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "src/preload/index.ts"),
capturePreload: resolve(__dirname, "src/main/capture/capturePreload.ts")
}
}
}
},
renderer: {
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src")
}
},
css: {
postcss: {
plugins: [tailwindcss()]
}
}
}
});
46 changes: 46 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@lapse/lapse-desktop",
"version": "2.0.0",
"description": "Lapse desktop client — timelapse recording for Hack Clubbers",
"private": true,
"type": "module",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "tsc --noEmit && electron-vite build",
"build:mac": "pnpm build && electron-builder --mac",
"build:win": "pnpm build && electron-builder --win",
"build:linux": "pnpm build && electron-builder --linux",
"build:all": "pnpm build && electron-builder --mac --win --linux",
"check-types": "tsc --noEmit",
"preview": "electron-vite preview"
},
"dependencies": {
"@hackclub/lapse-api": "workspace:*",
"@hackclub/lapse-shared": "workspace:*",
"@orpc/client": "^1.13.6",
"@orpc/contract": "^1.13.5",
"@orpc/openapi-client": "^1.13.5",
"electron-updater": "^6.6.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"jsonrepair": "^3.13.3",
"react-router": "^7.6.3",
"tus-js-client": "^4.3.1",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-react": "^5.1.2",
"electron": "^35.7.5",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
}
85 changes: 85 additions & 0 deletions apps/desktop/src/main/auth/apiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as fs from "node:fs";
import type { JsonifiedClient } from "@orpc/openapi-client";
import type { ContractRouterClient } from "@orpc/contract";
import { createORPCClient } from "@orpc/client";
import { OpenAPILink } from "@orpc/openapi-client/fetch";
import { compositeRouterContract } from "@hackclub/lapse-api";
import * as tus from "tus-js-client";
import { authService } from "./oauthFlow";

const API_URL = process.env.LAPSE_API_URL ?? "https://api.lapse.hackclub.com";

const link = new OpenAPILink(compositeRouterContract, {
url: `${API_URL}/api`,
headers: () => {
const token = authService.getToken();

return {
Authorization: token ? `Bearer ${token}` : undefined
};
}
});

/**
* The main oRPC API client for the Electron main process. Uses the same
* contracts as the web client (`compositeRouterContract`) so that all
* type-safe route helpers are available.
*/
export const api: JsonifiedClient<ContractRouterClient<typeof compositeRouterContract>> = createORPCClient(link);

/**
* Convenience wrapper that exposes common API helpers.
*/
export const apiClient = {
/**
* Fetches the currently authenticated user, or `null` if not signed in
* or the request fails.
*/
async getUser() {
const result = await api.user.myself({});
if ("data" in result && result.data) {
return (result.data as { user: { id: string; handle: string; displayName: string; profilePictureUrl: string | null } | null }).user;
}
return null;
}
};

/**
* Handles resumable multipart uploads via `tus`, consuming a single-use
* upload `token`. Reads a file from disk using `fs.createReadStream` so
* that large recordings do not need to be buffered entirely in memory.
*/
export async function apiUpload(
token: string,
filePath: string,
onProgress?: (uploaded: number, total: number) => void
): Promise<void> {
const stat = fs.statSync(filePath);
const stream = fs.createReadStream(filePath);

return new Promise<void>((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const upload = new tus.Upload(stream as any, {
endpoint: `${API_URL}/upload`,
retryDelays: [0, 3000, 5000, 10000, 20000],
uploadSize: stat.size,
headers: {
authorization: `Bearer ${token}`
},
onProgress: onProgress
? (bytesUploaded: number, bytesTotal: number) => {
onProgress(bytesUploaded, bytesTotal);
}
: undefined,
onSuccess() {
resolve();
},
onError(error) {
console.error("(apiClient.ts) upload failed!", error);
reject(error);
}
});

upload.start();
});
}
Loading
Loading