├── .gitignore ├── .prettierrc ├── src ├── legacy │ ├── constants.ts │ ├── validatePlatform.ts │ ├── checkPlatform.ts │ ├── validatePlatform.test.ts │ ├── handler.ts │ └── checkPlatform.test.ts ├── types.ts ├── index.ts ├── utils │ ├── fileExt.ts │ ├── versioning.test.ts │ ├── versioning.ts │ └── fileExt.test.ts ├── getPlatform.ts ├── index.test.ts ├── constants.ts ├── getPlatform.test.ts ├── services │ ├── github.test.ts │ └── github.ts └── handler.ts ├── wrangler.toml ├── worker-configuration.d.ts ├── vitest.config.js ├── .github ├── dependabot.yml └── workflows │ ├── deploy.yml │ ├── ci.yml │ └── codeql.yml ├── tsconfig.json ├── LICENSE.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | transpiled 4 | /.idea/ 5 | .dev.vars 6 | .wrangler/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "trailingComma": "none", 5 | "tabWidth": 4, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /src/legacy/constants.ts: -------------------------------------------------------------------------------- 1 | export enum LEGACY_AVAILABLE_PLATFORMS { 2 | MacOS = 'darwin', 3 | Win32 = 'win32', 4 | Win64 = 'win64', 5 | Linux = 'linux' 6 | } 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type TauriUpdateResponse = { 2 | url: string; 3 | version: string; 4 | notes?: string; 5 | pub_date?: string; 6 | signature?: string; 7 | }; 8 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "tauri-update-server" 2 | compatibility_date = "2024-05-12" 3 | compatibility_flags = ["nodejs_compat"] 4 | main = "src/index.ts" 5 | 6 | [vars] 7 | GITHUB_ACCOUNT = '0PandaDEV' 8 | GITHUB_REPO = 'Qopy' 9 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler on Fri May 10 2024 20:37:28 GMT-0400 (Eastern Daylight Time) 2 | // by running `wrangler types` 3 | 4 | export interface Env { 5 | GITHUB_ACCOUNT: string; 6 | GITHUB_REPO: string; 7 | GITHUB_API_TOKEN?: string; 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: './wrangler.toml' } 8 | } 9 | } 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from './handler'; 2 | import { Request, ExecutionContext } from '@cloudflare/workers-types'; 3 | import { Env } from '../worker-configuration'; 4 | 5 | export default { 6 | async fetch(request: Request, env: Env, ctx: ExecutionContext) { 7 | return handleRequest(request, env, ctx); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/fileExt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the file extension from a filename 3 | * @param filename ex: file.txt or file.json 4 | * @returns file extension 5 | */ 6 | export function fileExt(filename: string): string { 7 | const extName: string = filename.split('.').pop() || ''; 8 | if (!extName || extName === filename) { 9 | throw new Error('No file extension found'); 10 | } 11 | return extName; 12 | } 13 | -------------------------------------------------------------------------------- /src/legacy/validatePlatform.ts: -------------------------------------------------------------------------------- 1 | import { LEGACY_AVAILABLE_PLATFORMS } from './constants'; 2 | 3 | export function validatePlatform(platform: string): string | undefined { 4 | switch (platform) { 5 | case LEGACY_AVAILABLE_PLATFORMS.MacOS: 6 | case LEGACY_AVAILABLE_PLATFORMS.Win32: 7 | case LEGACY_AVAILABLE_PLATFORMS.Win64: 8 | case LEGACY_AVAILABLE_PLATFORMS.Linux: 9 | return platform; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/versioning.test.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeVersion } from './versioning'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('sanitizeVersion', () => { 5 | it("removes the 'v' prefix from a version string", () => { 6 | expect(sanitizeVersion('v1.0.0')).toBe('1.0.0'); 7 | expect(sanitizeVersion('1.0.0')).toBe('1.0.0'); 8 | }); 9 | it('throws error if the version string is empty', () => { 10 | expect(() => sanitizeVersion('')).toThrowError('No version found'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /src/utils/versioning.ts: -------------------------------------------------------------------------------- 1 | import semverValid from 'semver/functions/valid'; 2 | import semverGt from 'semver/functions/gt'; 3 | 4 | /** 5 | * Normalize version string 6 | * @param version ex: v1.0.0 or 1.0.0 7 | * @returns normalized version string 8 | */ 9 | export function sanitizeVersion(version: string): string | undefined { 10 | // Works with or without v in version 11 | const semanticV = version.split('v').pop(); 12 | if (!semanticV || semanticV.length === 0) { 13 | throw new Error('No version found'); 14 | } 15 | return semanticV; 16 | } 17 | 18 | export { semverGt }; 19 | export { semverValid }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": [ 14 | "@cloudflare/workers-types", 15 | "@cloudflare/workers-types/experimental", 16 | "@cloudflare/vitest-pool-workers" 17 | ] 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | repository_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | name: Deploy 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Publish 18 | uses: cloudflare/wrangler-action@v3 19 | with: 20 | apiToken: ${{ secrets.CF_API_TOKEN }} 21 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 22 | secrets: ${{ secrets.GITHUB_API_TOKEN != '' && 'GITHUB_API_TOKEN' || '' }} 23 | env: 24 | GITHUB_API_TOKEN: ${{ secrets.GITHUB_API_TOKEN }} 25 | -------------------------------------------------------------------------------- /src/utils/fileExt.test.ts: -------------------------------------------------------------------------------- 1 | import { fileExt } from './fileExt'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('fileExt', () => { 5 | it('returns the file extension', () => { 6 | expect(fileExt('file.txt')).toBe('txt'); 7 | expect(fileExt('file.json')).toBe('json'); 8 | expect(fileExt('file.tar.gz')).toBe('gz'); 9 | }); 10 | it('throws an error if no file extension is found', () => { 11 | expect(() => fileExt('file')).toThrowError('No file extension found'); 12 | }); 13 | it('throws an error when no file is provided', () => { 14 | expect(() => fileExt('')).toThrowError('No file extension found'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install dependencies 18 | run: npm install 19 | - name: Lint 20 | run: npm run lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 5 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Install dependencies 28 | run: npm install 29 | - name: Test 30 | run: npm test -------------------------------------------------------------------------------- /src/getPlatform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ARCH_FILTERS, 3 | AVAILABLE_ARCHITECTURES, 4 | AVAILABLE_PLATFORMS, 5 | PLATFORM_FILTERS 6 | } from './constants'; 7 | import { fileExt } from './utils/fileExt'; 8 | 9 | export function testAsset( 10 | target: AVAILABLE_PLATFORMS, 11 | arch: AVAILABLE_ARCHITECTURES, 12 | fileName: string 13 | ): boolean { 14 | const { matches, extension } = PLATFORM_FILTERS[target]; 15 | const arch_matches = ARCH_FILTERS[arch]; 16 | const rightArch = 17 | arch_matches && 18 | ARCH_FILTERS[arch].some((arch) => fileName.includes(arch)); 19 | 20 | // .app gz files don't have arch in the name 21 | if (!rightArch && target !== AVAILABLE_PLATFORMS.MacOS) { 22 | console.error(`File ${fileName} has wrong architecture`); 23 | return false; 24 | } 25 | 26 | if (fileExt(fileName) !== extension) { 27 | console.error(`File ${fileName} has wrong extension`); 28 | return false; 29 | } 30 | 31 | return matches.some((match) => fileName.includes(match)); 32 | } 33 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createExecutionContext, 3 | waitOnExecutionContext 4 | } from 'cloudflare:test'; 5 | import { describe, it, expect } from 'vitest'; 6 | import { Env } from '../worker-configuration'; 7 | import worker from '../src'; 8 | 9 | // For now, you'll need to do something like this to get a correctly-typed 10 | // `Request` to pass to `worker.fetch()`. 11 | const IncomingRequest = Request; 12 | 13 | describe('handle GET', () => { 14 | it('responds with 200', async () => { 15 | const request = new IncomingRequest('http://example.com'); 16 | // Create an empty context to pass to `worker.fetch()` 17 | const ctx = createExecutionContext(); 18 | const env: Env = { 19 | GITHUB_ACCOUNT: 'killeencode', 20 | GITHUB_REPO: 'brancato' 21 | }; 22 | const response = await worker.fetch(request, env, ctx); 23 | // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions 24 | await waitOnExecutionContext(ctx); 25 | expect(response.status).toBe(200); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum AVAILABLE_PLATFORMS { 2 | MacOS = 'darwin', 3 | Windows = 'windows', 4 | Linux = 'linux' 5 | } 6 | 7 | export enum AVAILABLE_ARCHITECTURES { 8 | x64 = 'x86_64', 9 | x86 = 'i686', 10 | arm64 = 'aarch64', 11 | armv7 = 'armv7' 12 | } 13 | 14 | //TODO: check if this is correct 15 | export const ARCH_FILTERS = { 16 | [AVAILABLE_ARCHITECTURES.x64]: ['x64', 'amd64'], 17 | [AVAILABLE_ARCHITECTURES.x86]: ['x86'], 18 | [AVAILABLE_ARCHITECTURES.arm64]: ['arm64'], 19 | [AVAILABLE_ARCHITECTURES.armv7]: ['armv7'] 20 | } as { [key in AVAILABLE_ARCHITECTURES]: string[] }; 21 | 22 | export type Filter = { 23 | extension: string; 24 | matches: string[]; 25 | }; 26 | export const PLATFORM_FILTERS = { 27 | [AVAILABLE_PLATFORMS.MacOS]: { 28 | extension: 'gz', 29 | matches: ['.app', 'osx'] 30 | }, 31 | [AVAILABLE_PLATFORMS.Windows]: { 32 | extension: 'zip', 33 | matches: ['x86', 'x64', 'x32'] 34 | }, 35 | [AVAILABLE_PLATFORMS.Linux]: { 36 | extension: 'gz', 37 | matches: ['AppImage'] 38 | } 39 | } as { 40 | [key in AVAILABLE_PLATFORMS]: Filter; 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | - Current version of this software: Copyright 2024 Tricities Media Group LLC 4 | - Original version of this software: Copyright (c) 2022 KilleenCode 5 | - Template used by the original version: Copyright (c) 2020 Cloudflare, Inc. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker-typescript-template", 3 | "version": "2.0.0", 4 | "description": "Worker based Tauri update server", 5 | "main": "dist/worker.js", 6 | "scripts": { 7 | "dev": "wrangler dev", 8 | "format": "prettier --write '*.{json,js}' 'src/**/*.{js,ts}'", 9 | "lint": "eslint --max-warnings=0 src && prettier --check '*.{json,js}' 'src/**/*.{js,ts}'", 10 | "test": "vitest" 11 | }, 12 | "author": "author", 13 | "license": "MIT", 14 | "eslintConfig": { 15 | "root": true, 16 | "extends": [ 17 | "typescript", 18 | "prettier" 19 | ] 20 | }, 21 | "devDependencies": { 22 | "@cloudflare/vitest-pool-workers": "^0.4.0", 23 | "@cloudflare/workers-types": "^4.20240821.1", 24 | "@types/semver": "^7.5.8", 25 | "@typescript-eslint/eslint-plugin": "^7.13.0", 26 | "@typescript-eslint/parser": "^7.18.0", 27 | "eslint": "^8.56.0", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-config-typescript": "^3.0.0", 30 | "prettier": "^3.3.2", 31 | "ts-loader": "^9.5.1", 32 | "typescript": "^5.5.4", 33 | "vitest": "1.3.0", 34 | "wrangler": "^3.58.0" 35 | }, 36 | "dependencies": { 37 | "semver": "^7.6.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/legacy/checkPlatform.ts: -------------------------------------------------------------------------------- 1 | import { LEGACY_AVAILABLE_PLATFORMS } from './constants'; 2 | import { fileExt } from '../utils/fileExt'; 3 | 4 | export function checkPlatform( 5 | platform: string, 6 | fileName: string 7 | ): string | undefined { 8 | const extension = fileExt(fileName); 9 | // OSX we should have our .app tar.gz 10 | if ( 11 | (fileName.includes('.app') || 12 | fileName.includes('darwin') || 13 | fileName.includes('osx')) && 14 | extension === 'gz' && 15 | platform === LEGACY_AVAILABLE_PLATFORMS.MacOS 16 | ) { 17 | return 'darwin'; 18 | } 19 | 20 | // Windows 64 bits 21 | if ( 22 | (fileName.includes('x64') || fileName.includes('win64')) && 23 | extension === 'zip' && 24 | platform === LEGACY_AVAILABLE_PLATFORMS.Win64 25 | ) { 26 | return 'win64'; 27 | } 28 | 29 | // Windows 32 bits 30 | if ( 31 | (fileName.includes('x32') || fileName.includes('win32')) && 32 | extension === 'zip' && 33 | platform === LEGACY_AVAILABLE_PLATFORMS.Win32 34 | ) { 35 | return 'win32'; 36 | } 37 | 38 | // Linux app image 39 | if ( 40 | fileName.includes('AppImage') && 41 | extension === 'gz' && 42 | platform === LEGACY_AVAILABLE_PLATFORMS.Linux 43 | ) { 44 | return 'linux'; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/getPlatform.test.ts: -------------------------------------------------------------------------------- 1 | import { AVAILABLE_PLATFORMS, AVAILABLE_ARCHITECTURES } from './constants'; 2 | import { testAsset } from './getPlatform'; 3 | import { describe, it, expect } from 'vitest'; 4 | 5 | describe('testAsset', () => { 6 | it('returns true for matching platform, architecture, and file extension', () => { 7 | const target = AVAILABLE_PLATFORMS.Windows; 8 | const arch = AVAILABLE_ARCHITECTURES.x86; 9 | const fileName = 'example-x86.zip'; 10 | 11 | const result = testAsset(target, arch, fileName); 12 | 13 | expect(result).toBe(true); 14 | }); 15 | 16 | it('returns false for non-matching platform', () => { 17 | const target = AVAILABLE_PLATFORMS.Linux; 18 | const arch = AVAILABLE_ARCHITECTURES.x86; 19 | const fileName = 'example-x86.zip'; 20 | 21 | const result = testAsset(target, arch, fileName); 22 | 23 | expect(result).toBe(false); 24 | }); 25 | 26 | it('returns false for non-matching architecture', () => { 27 | const target = AVAILABLE_PLATFORMS.Windows; 28 | const arch = AVAILABLE_ARCHITECTURES.arm64; 29 | const fileName = 'example-x86.zip'; 30 | 31 | const result = testAsset(target, arch, fileName); 32 | 33 | expect(result).toBe(false); 34 | }); 35 | 36 | it('returns false for non-matching file extension', () => { 37 | const target = AVAILABLE_PLATFORMS.Windows; 38 | const arch = AVAILABLE_ARCHITECTURES.x86; 39 | const fileName = 'example-x86.gz'; 40 | 41 | const result = testAsset(target, arch, fileName); 42 | 43 | expect(result).toBe(false); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/legacy/validatePlatform.test.ts: -------------------------------------------------------------------------------- 1 | import { validatePlatform } from './validatePlatform'; 2 | import { LEGACY_AVAILABLE_PLATFORMS } from './constants'; 3 | import { describe, it, expect } from 'vitest'; 4 | 5 | describe('validatePlatform', () => { 6 | it('should return the platform if it is valid using enum', () => { 7 | expect(validatePlatform(LEGACY_AVAILABLE_PLATFORMS.MacOS)).toBe( 8 | LEGACY_AVAILABLE_PLATFORMS.MacOS 9 | ); 10 | expect(validatePlatform(LEGACY_AVAILABLE_PLATFORMS.Win32)).toBe( 11 | LEGACY_AVAILABLE_PLATFORMS.Win32 12 | ); 13 | expect(validatePlatform(LEGACY_AVAILABLE_PLATFORMS.Win64)).toBe( 14 | LEGACY_AVAILABLE_PLATFORMS.Win64 15 | ); 16 | expect(validatePlatform(LEGACY_AVAILABLE_PLATFORMS.Linux)).toBe( 17 | LEGACY_AVAILABLE_PLATFORMS.Linux 18 | ); 19 | }); 20 | 21 | it('should return the platform if it is valid using string', () => { 22 | expect(validatePlatform('darwin')).toBe( 23 | LEGACY_AVAILABLE_PLATFORMS.MacOS 24 | ); 25 | expect(validatePlatform('win32')).toBe( 26 | LEGACY_AVAILABLE_PLATFORMS.Win32 27 | ); 28 | expect(validatePlatform('win64')).toBe( 29 | LEGACY_AVAILABLE_PLATFORMS.Win64 30 | ); 31 | expect(validatePlatform('linux')).toBe( 32 | LEGACY_AVAILABLE_PLATFORMS.Linux 33 | ); 34 | }); 35 | 36 | it('should return undefined if the platform is not valid', () => { 37 | expect(validatePlatform('InvalidPlatform')).toBeUndefined(); 38 | expect(validatePlatform('Linux')).toBeUndefined(); 39 | expect(validatePlatform('Win64')).toBeUndefined(); 40 | expect(validatePlatform('Mac')).toBeUndefined(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/services/github.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { 3 | getReleases, 4 | getLatestRelease, 5 | findAssetSignature, 6 | Asset 7 | } from './github'; 8 | import { Env } from '../../worker-configuration'; 9 | 10 | const IncomingRequest = Request; 11 | 12 | // Mock the Env object 13 | const env: Env = { 14 | GITHUB_ACCOUNT: 'killeencode', 15 | GITHUB_REPO: 'brancato' 16 | }; 17 | 18 | describe('github', () => { 19 | it('getReleases', async () => { 20 | const request = new IncomingRequest('http://example.com'); 21 | const response = await getReleases(request, env); 22 | expect(response).toBeInstanceOf(Response); 23 | expect(response.status).toBe(200); 24 | }); 25 | 26 | it('getLatestRelease', async () => { 27 | const request = new IncomingRequest('http://example.com'); 28 | const release = await getLatestRelease(request, env); 29 | expect(release).toEqual( 30 | expect.objectContaining({ 31 | tag_name: expect.any(String), 32 | assets: expect.any(Array), 33 | body: expect.any(String), 34 | published_at: expect.any(String) 35 | }) 36 | ); 37 | }); 38 | 39 | it('findAssetSignature', async () => { 40 | const assets: Asset[] = [ 41 | { name: 'test.sig', browser_download_url: 'http://example.com' }, 42 | { name: 'lol.sig', browser_download_url: 'http://example.com/lol' }, 43 | { 44 | name: 'test.zip', 45 | browser_download_url: 'http://example.com/test' 46 | } 47 | ]; 48 | const signature = await findAssetSignature('test', assets); 49 | expect(signature).toContain('Example Domain'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tauri Update Server: Cloudflare 2 | 3 | ## Overview 4 | This tool allows you to easily take advantage of Tauri's dynamic update server capabilities using Cloudflare Workers. This project is a fork of [KilleenCode/tauri-update-cloudflare](https://github.com/KilleenCode/tauri-update-cloudflare), which hasn't been maintained for a while. This fork aims to keep the project up-to-date with the latest Tauri and Cloudflare Workers APIs. 5 | 6 | ## Configuration 7 | Wrangler.toml variables: 8 | - `GITHUB_ACCOUNT`: GitHub account name (e.g. `mackenly`) 9 | - `GITHUB_REPO`: GitHub repository name (e.g. `my-tauri-app`) 10 | 11 | This project works with private GitHub repositories. To enable this, you need to set the `GITHUB_API_TOKEN` secret in your GitHub repository and it will be picked up by the deployment action. Alternatively, add the secret [manually](https://developers.cloudflare.com/workers/configuration/secrets/#add-secrets-to-your-project) for manual deployments. This token should have the `repo` permission. Create a new token [here](https://github.com/settings/tokens/new). 12 | 13 | ## Deployment 14 | ### With CI/CD GitHub Actions 15 | CI/CD via GitHub Actions is set up to test and lint the code. CD requires the repository owner to set two repository secrets: 16 | - `CF_API_TOKEN` - Cloudflare API token (requires 'Edit Cloudflare Workers' permission template) 17 | - `CLOUDFLARE_ACCOUNT_ID` - Cloudflare account ID 18 | 19 | ### Manual Deployment 20 | 1. Install dependencies: `npm install` and ensure `wrangler` [is installed](https://developers.cloudflare.com/workers/wrangler/install-and-update/). 21 | 2. Run `npm run test` to ensure the code is working as expected. 22 | 3. Run `wrangler deploy` to deploy the code to Cloudflare Workers. 23 | 24 | ## Usage with Tauri 25 | ### Tauri >= v1.0.0-rc5: 26 | 27 | use `https://your-update-server.com/v1` route 28 | 29 | Read the [Tauri documentation](https://tauri.app/v1/guides/distribution/updater#tauri-configuration) for more information. For an example usage, see [Brancato config](https://github.com/KilleenCode/brancato/blob/main/src-tauri/tauri.conf.json#L55). 30 | 31 | ### Legacy 32 | use `https://your-update-server.com/` 33 | 34 | ## Disclaimer 35 | Not affiliated with Rust, Tauri, or Cloudflare. Use at your own risk and follow the [license](./LICENSE). -------------------------------------------------------------------------------- /src/legacy/handler.ts: -------------------------------------------------------------------------------- 1 | import { validatePlatform } from './validatePlatform'; 2 | import { checkPlatform } from './checkPlatform'; 3 | import { Asset, findAssetSignature, getReleases } from '../services/github'; 4 | import { TauriUpdateResponse } from '../types'; 5 | import { sanitizeVersion, semverGt, semverValid } from '../utils/versioning'; 6 | 7 | import { Env } from '../../worker-configuration'; 8 | import { Request } from '@cloudflare/workers-types'; 9 | 10 | export async function handleLegacyRequest( 11 | request: Request, 12 | env: Env 13 | ): Promise { 14 | const path = new URL(request.url).pathname; 15 | const [platform, version] = path.slice(1).split('/'); 16 | 17 | const releases = await getReleases(request, env); 18 | 19 | const release = (await releases.clone().json()) as { 20 | tag_name: string; 21 | assets: Asset[]; 22 | body: string; 23 | published_at: string; 24 | }; 25 | if (!platform || !validatePlatform(platform) || !version) { 26 | return releases; 27 | } 28 | const remoteVersion = sanitizeVersion(release.tag_name.toLowerCase()); 29 | 30 | if (!remoteVersion || !semverValid(remoteVersion)) { 31 | return new Response('Not found', { status: 404 }); 32 | } 33 | 34 | const shouldUpdate = semverGt(remoteVersion, version); 35 | if (!shouldUpdate) { 36 | return new Response(null, { status: 204 }); 37 | } 38 | 39 | for (const asset of release.assets) { 40 | const { name, browser_download_url } = asset; 41 | const findPlatform = checkPlatform(platform, name); 42 | if (!findPlatform) { 43 | continue; 44 | } 45 | 46 | // try to find signature for this asset 47 | const signature = await findAssetSignature(name, release.assets); 48 | const data: TauriUpdateResponse = { 49 | url: browser_download_url, 50 | version: remoteVersion, 51 | notes: release.body, 52 | pub_date: release.published_at, 53 | signature 54 | }; 55 | return new Response(JSON.stringify(data), { 56 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 57 | }); 58 | } 59 | 60 | return new Response( 61 | JSON.stringify({ remoteVersion, version, shouldUpdate }) 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/legacy/checkPlatform.test.ts: -------------------------------------------------------------------------------- 1 | import { checkPlatform } from './checkPlatform'; 2 | import { LEGACY_AVAILABLE_PLATFORMS } from './constants'; 3 | import { describe, it, expect } from 'vitest'; 4 | 5 | describe('checkPlatform', () => { 6 | it('should return "darwin" for MacOS .app tar.gz files', () => { 7 | expect( 8 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.MacOS, 'myApp.app.tar.gz') 9 | ).toBe('darwin'); 10 | expect( 11 | checkPlatform( 12 | LEGACY_AVAILABLE_PLATFORMS.MacOS, 13 | 'myApp.darwin.tar.gz' 14 | ) 15 | ).toBe('darwin'); 16 | expect( 17 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.MacOS, 'myApp.osx.tar.gz') 18 | ).toBe('darwin'); 19 | }); 20 | 21 | it('should return "win64" for Windows 64 bits zip files', () => { 22 | expect( 23 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Win64, 'myApp.x64.zip') 24 | ).toBe('win64'); 25 | expect( 26 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Win64, 'myApp.win64.zip') 27 | ).toBe('win64'); 28 | }); 29 | 30 | it('should return "win32" for Windows 32 bits zip files', () => { 31 | expect( 32 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Win32, 'myApp.x32.zip') 33 | ).toBe('win32'); 34 | expect( 35 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Win32, 'myApp.win32.zip') 36 | ).toBe('win32'); 37 | }); 38 | 39 | it('should return "linux" for Linux AppImage gz files', () => { 40 | expect( 41 | checkPlatform( 42 | LEGACY_AVAILABLE_PLATFORMS.Linux, 43 | 'myApp.AppImage.tar.gz' 44 | ) 45 | ).toBe('linux'); 46 | }); 47 | 48 | it('should return undefined for non-matching files', () => { 49 | expect( 50 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.MacOS, 'myApp.exe') 51 | ).toBeUndefined(); 52 | expect( 53 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Win64, 'myApp.app.tar.gz') 54 | ).toBeUndefined(); 55 | expect( 56 | checkPlatform( 57 | LEGACY_AVAILABLE_PLATFORMS.Win32, 58 | 'myApp.AppImage.tar.gz' 59 | ) 60 | ).toBeUndefined(); 61 | expect( 62 | checkPlatform(LEGACY_AVAILABLE_PLATFORMS.Linux, 'myApp.x64.zip') 63 | ).toBeUndefined(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/services/github.ts: -------------------------------------------------------------------------------- 1 | import { Env } from '../../worker-configuration'; 2 | import { Request, Response } from '@cloudflare/workers-types'; 3 | 4 | export type Asset = { 5 | name: string; 6 | browser_download_url: string; 7 | }; 8 | 9 | type Release = { 10 | tag_name: string; 11 | assets: Asset[]; 12 | body: string; 13 | published_at: string; 14 | }; 15 | 16 | /** 17 | * Get the latest release from the GitHub API 18 | * @param request The Worker Request object from the fetch event 19 | * @param env The Worker environment object 20 | * @returns Response object from the GitHub API 21 | */ 22 | export async function getReleases( 23 | request: Request, 24 | env: Env 25 | ): Promise { 26 | // build the request headers conditionally 27 | const headers = new Headers({ 28 | Accept: 'application/vnd.github.preview', 29 | 'User-Agent': request.headers.get('User-Agent') as string 30 | }); 31 | 32 | if (env.GITHUB_API_TOKEN?.length) { 33 | headers.set('Authorization', `token ${env.GITHUB_API_TOKEN}`); 34 | } 35 | 36 | // @ts-expect-error - Fetch does not have the webSocket property 37 | return await fetch( 38 | `https://api.github.com/repos/${env.GITHUB_ACCOUNT}/${env.GITHUB_REPO}/releases/latest`, 39 | { 40 | method: 'GET', 41 | headers 42 | } 43 | ); 44 | } 45 | 46 | /** 47 | * Get the latest release from the GitHub API as a Release object 48 | * @param request The Worker Request object from the fetch event 49 | * @param env The Worker environment object 50 | * @returns The latest release as a Release object 51 | */ 52 | export async function getLatestRelease( 53 | request: Request, 54 | env: Env 55 | ): Promise { 56 | const releases: Response = await getReleases(request, env); 57 | return (await releases.json()) as Release; 58 | } 59 | 60 | /** 61 | * Find the signature file for a given asset 62 | * @param fileName The name of the file to find the signature for 63 | * @param assets The assets to search for the signature 64 | * @returns The signature as a string or undefined if not found 65 | */ 66 | export async function findAssetSignature( 67 | fileName: string, 68 | assets: Asset[] 69 | ): Promise { 70 | // check in our assets if we have a file: `fileName.sig` 71 | // by example fileName can be: App-1.0.0.zip 72 | 73 | const foundSignature = assets.find( 74 | (asset) => asset.name.toLowerCase() === `${fileName.toLowerCase()}.sig` 75 | ); 76 | 77 | if (!foundSignature) { 78 | return undefined; 79 | } 80 | 81 | const response = await fetch(foundSignature.browser_download_url); 82 | if (response.status !== 200) { 83 | return undefined; 84 | } 85 | 86 | return await response.text(); 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '38 16 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { testAsset } from './getPlatform'; 2 | import semverValid from 'semver/functions/valid'; 3 | import semverGt from 'semver/functions/gt'; 4 | import { AVAILABLE_ARCHITECTURES, AVAILABLE_PLATFORMS } from './constants'; 5 | import { handleLegacyRequest } from './legacy/handler'; 6 | import { findAssetSignature, getLatestRelease } from './services/github'; 7 | import { TauriUpdateResponse } from './types'; 8 | import { sanitizeVersion } from './utils/versioning'; 9 | 10 | import { Request, ExecutionContext } from '@cloudflare/workers-types'; 11 | import { WritableStream as WebWritableStream } from 'node:stream/web'; 12 | import { Env } from '../worker-configuration'; 13 | 14 | const SendJSON = (data: Record) => { 15 | return new Response(JSON.stringify(data), { 16 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 17 | }); 18 | }; 19 | 20 | const responses = { 21 | NotFound: () => new Response('Not found', { status: 404 }), 22 | NoContent: () => new Response(null, { status: 204 }), 23 | SendUpdate: (data: TauriUpdateResponse) => SendJSON(data), 24 | SendJSON 25 | }; 26 | 27 | type RequestPathParts = [ 28 | string, 29 | AVAILABLE_PLATFORMS, 30 | AVAILABLE_ARCHITECTURES, 31 | string 32 | ]; 33 | const handleV1Request = async ( 34 | request: Request, 35 | env: Env, 36 | ctx: ExecutionContext 37 | ) => { 38 | const path = new URL(request.url).pathname; 39 | const [, target, arch, appVersion] = path 40 | .slice(1) 41 | .split('/') as RequestPathParts; 42 | 43 | if (!target || !arch || !appVersion || !semverValid(appVersion)) { 44 | return responses.NotFound(); 45 | } 46 | const release = await getLatestRelease(request, env); 47 | 48 | const remoteVersion = sanitizeVersion(release.tag_name.toLowerCase()); 49 | if (!remoteVersion || !semverValid(remoteVersion)) { 50 | return responses.NotFound(); 51 | } 52 | 53 | const shouldUpdate = semverGt(remoteVersion, appVersion); 54 | if (!shouldUpdate) { 55 | return responses.NoContent(); 56 | } 57 | 58 | const match = release.assets.find(({ name }) => { 59 | const test = testAsset(target, arch, name); 60 | 61 | return test; 62 | }); 63 | 64 | if (typeof match === 'undefined') { 65 | return responses.NotFound(); 66 | } 67 | 68 | const signature = await findAssetSignature(match.name, release.assets); 69 | const proxy = env.GITHUB_API_TOKEN?.length; 70 | const downloadURL = proxy 71 | ? createProxiedFileUrl(request, env, ctx, match.browser_download_url) 72 | : match.browser_download_url; 73 | const data: TauriUpdateResponse = { 74 | url: downloadURL, 75 | version: remoteVersion, 76 | notes: release.body, 77 | pub_date: release.published_at, 78 | signature 79 | }; 80 | 81 | return responses.SendUpdate(data); 82 | }; 83 | 84 | const createProxiedFileUrl = ( 85 | request: Request, 86 | env: Env, 87 | ctx: ExecutionContext, 88 | downloadURL: string 89 | ) => { 90 | const fileName = downloadURL.split('/')?.at(-1); 91 | if (!fileName) { 92 | throw new Error('Could not get file name from download URL'); 93 | } 94 | 95 | const path = new URL(request.url); 96 | const root = `${path.protocol}//${path.host}`; 97 | 98 | return new URL(`/latest/${fileName}`, root).toString(); 99 | }; 100 | 101 | const getLatestAssets = async ( 102 | request: Request, 103 | env: Env, 104 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 105 | ctx: ExecutionContext 106 | ) => { 107 | const fileName = request.url.split('/')?.at(-1); 108 | if (!fileName) { 109 | throw new Error('Could not get file name from download URL'); 110 | } 111 | 112 | const release = await getLatestRelease(request, env); 113 | const downloadPath = release.assets.find( 114 | ({ name }) => name === fileName 115 | )?.browser_download_url; 116 | 117 | if (!downloadPath) { 118 | throw new Error('Could not get file path from download URL'); 119 | } 120 | 121 | const { readable } = new TransformStream(); 122 | const file_response = await fetch(downloadPath, { 123 | method: 'GET', 124 | redirect: 'follow' 125 | }); 126 | 127 | if (file_response.body) { 128 | const webWritableStream = new WebWritableStream(); 129 | await file_response.body.pipeTo(webWritableStream); 130 | return new Response(readable, file_response); 131 | } 132 | 133 | throw new Error('Could not get file body from download URL'); 134 | }; 135 | 136 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 137 | export async function handleRequest( 138 | request: Request, 139 | env: Env, 140 | ctx: ExecutionContext 141 | ): Promise { 142 | const path = new URL(request.url).pathname; 143 | 144 | if (path.includes('/latest')) { 145 | return getLatestAssets(request, env, ctx); 146 | } 147 | const version = path.slice(1).split('/')[0]; 148 | 149 | if (version.includes('v')) { 150 | switch (version) { 151 | case 'v1': 152 | default: 153 | return handleV1Request(request, env, ctx); 154 | } 155 | } 156 | 157 | return handleLegacyRequest(request, env); 158 | } 159 | --------------------------------------------------------------------------------