diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm-
index 8aaeafa..5789f4e 100644
--- a/.task/checksum/build-frontend--DEV--RUNNER-npm-
+++ b/.task/checksum/build-frontend--DEV--RUNNER-npm-
@@ -1 +1 @@
-8ae8a50dd0498aff1d9aa400b1da4f94
+e6d9601aa27dccaf278ac4a3f7247ae2
diff --git a/.task/checksum/common-generate-bindings b/.task/checksum/common-generate-bindings
new file mode 100644
index 0000000..45275be
--- /dev/null
+++ b/.task/checksum/common-generate-bindings
@@ -0,0 +1 @@
+bfcb33c24266fe5f8784f39a32fa2f2c
diff --git a/.task/checksum/common-install-frontend-deps-npm b/.task/checksum/common-install-frontend-deps-npm
new file mode 100644
index 0000000..11cd4f8
--- /dev/null
+++ b/.task/checksum/common-install-frontend-deps-npm
@@ -0,0 +1 @@
+2a6c359f91e7e7c95aa4b1f4064d40c
diff --git a/build/Taskfile.yml b/build/Taskfile.yml
index eeb2f37..ebbbebd 100644
--- a/build/Taskfile.yml
+++ b/build/Taskfile.yml
@@ -279,7 +279,7 @@ tasks:
summary: Builds and runs the Docker image
desc: |
Builds the Docker image and runs it, exposing port 8080.
- Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+ Usage: task run:docker [TAG=myapp:latest] [HOST=0.0.0.0] [PORT=8080]
Note: The internal container port is always 8080. The PORT variable
only changes the host port mapping. Ensure your app uses port 8080
or modify the Dockerfile to match your ServerOptions.Port setting.
@@ -289,9 +289,10 @@ tasks:
TAG:
ref: .TAG
cmds:
- - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+ - docker run --rm -p {{.HOST | default "0.0.0.0"}}:{{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
vars:
TAG: "{{.TAG}}"
+ HOST: "{{.HOST}}"
PORT: "{{.PORT}}"
setup:docker:
diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist
index 8a518a6..514c428 100644
--- a/build/darwin/Info.dev.plist
+++ b/build/darwin/Info.dev.plist
@@ -31,5 +31,7 @@
true
NSHumanReadableCopyright
(c) 2026, OPIc Flow
+ NSMicrophoneUsageDescription
+ OPIc Flow records your spoken practice answers for transcription and feedback.
\ No newline at end of file
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
index 96ff2b1..2f5aeb5 100644
--- a/build/darwin/Info.plist
+++ b/build/darwin/Info.plist
@@ -26,5 +26,7 @@
true
NSHumanReadableCopyright
(c) 2026, OPIc Flow
+ NSMicrophoneUsageDescription
+ OPIc Flow records your spoken practice answers for transcription and feedback.
\ No newline at end of file
diff --git a/build/windows/msix/app_manifest.xml b/build/windows/msix/app_manifest.xml
index 7a7dee3..90a6cbe 100644
--- a/build/windows/msix/app_manifest.xml
+++ b/build/windows/msix/app_manifest.xml
@@ -50,6 +50,7 @@
+
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 14e85e0..115c1f0 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,28 +1,85 @@
import { AppService } from "../bindings/opiccoach";
import type { AppSettings, ExamReport, Evaluation, Question } from "./types";
-export const api = {
- getSettings: () => AppService.GetSettings() as Promise,
- configure: (settings: Record) =>
- AppService.Configure(settings as never) as Promise,
- testConnection: () => AppService.TestConnection() as Promise,
- generateSpeech: (text: string) =>
- AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>,
- startSession: (config: Record) =>
- AppService.StartSession(config as never) as Promise<{
- sessionId: string;
- question: Question;
- totalCount: number;
- }>,
- submitAnswer: (answer: Record) =>
- AppService.SubmitAnswer(answer as never) as Promise<{
- transcript: string;
- evaluation: Evaluation;
- next?: Question;
- completed: boolean;
- progress: number;
- }>,
- finalizeSession: (sessionId: string) =>
- AppService.FinalizeSession(sessionId) as Promise,
- getReport: (sessionId: string) => AppService.GetReport(sessionId) as Promise
+type WailsWindow = Window & {
+ chrome?: { webview?: { postMessage?: unknown } };
+ webkit?: { messageHandlers?: { external?: { postMessage?: unknown } } };
+ wails?: { invoke?: unknown; invokeAsync?: unknown };
};
+
+const isWailsRuntime = () => {
+ if (typeof window === "undefined") return false;
+ const runtimeWindow = window as WailsWindow;
+ return Boolean(
+ runtimeWindow.chrome?.webview?.postMessage ||
+ runtimeWindow.webkit?.messageHandlers?.external?.postMessage ||
+ runtimeWindow.wails?.invoke ||
+ runtimeWindow.wails?.invokeAsync
+ );
+};
+
+async function request(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ ...init?.headers
+ }
+ });
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+ return response.json() as Promise;
+}
+
+export const api = {
+ getSettings: () => isWailsRuntime()
+ ? AppService.GetSettings() as Promise
+ : request("/api/settings"),
+ configure: (settings: Record) =>
+ isWailsRuntime()
+ ? AppService.Configure(settings as never) as Promise
+ : request("/api/configure", { method: "POST", body: JSON.stringify(settings) }),
+ testConnection: () => isWailsRuntime()
+ ? AppService.TestConnection() as Promise
+ : request("/api/test-connection", { method: "POST", body: "{}" }),
+ generateSpeech: (text: string) =>
+ isWailsRuntime()
+ ? AppService.GenerateSpeech(text) as Promise<{ audioBase64: string; mimeType: string }>
+ : request<{ audioBase64: string; mimeType: string }>("/api/speech", { method: "POST", body: JSON.stringify({ text }) }),
+ startSession: (config: Record) =>
+ isWailsRuntime()
+ ? AppService.StartSession(config as never) as Promise<{
+ sessionId: string;
+ question: Question;
+ totalCount: number;
+ }>
+ : request<{
+ sessionId: string;
+ question: Question;
+ totalCount: number;
+ }>("/api/sessions", { method: "POST", body: JSON.stringify(config) }),
+ submitAnswer: (answer: Record) =>
+ isWailsRuntime()
+ ? AppService.SubmitAnswer(answer as never) as Promise<{
+ transcript: string;
+ evaluation: Evaluation;
+ next?: Question;
+ completed: boolean;
+ progress: number;
+ }>
+ : request<{
+ transcript: string;
+ evaluation: Evaluation;
+ next?: Question;
+ completed: boolean;
+ progress: number;
+ }>("/api/answers", { method: "POST", body: JSON.stringify(answer) }),
+ finalizeSession: (sessionId: string) =>
+ isWailsRuntime()
+ ? AppService.FinalizeSession(sessionId) as Promise
+ : request(`/api/sessions/${sessionId}/finalize`, { method: "POST", body: "{}" }),
+ getReport: (sessionId: string) => isWailsRuntime()
+ ? AppService.GetReport(sessionId) as Promise
+ : request(`/api/sessions/${sessionId}/report`)
+};
\ No newline at end of file
diff --git a/main.go b/main.go
index b046035..27b708b 100644
--- a/main.go
+++ b/main.go
@@ -1,3 +1,5 @@
+//go:build !server
+
package main
import (
@@ -34,10 +36,12 @@ func main() {
MinHeight: 680,
BackgroundColour: application.NewRGB(246, 247, 243),
URL: "/",
+ Permissions: map[application.PermissionType]application.Permission{
+ application.PermissionMicrophone: application.PermissionAllow,
+ },
})
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
-
diff --git a/server_main.go b/server_main.go
new file mode 100644
index 0000000..b568551
--- /dev/null
+++ b/server_main.go
@@ -0,0 +1,150 @@
+//go:build server
+
+package main
+
+import (
+ "embed"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+//go:embed all:frontend/dist
+var serverAssets embed.FS
+
+func main() {
+ service := NewAppService()
+ mux := http.NewServeMux()
+ registerAPI(mux, service)
+ registerStatic(mux)
+
+ port := strings.TrimSpace(os.Getenv("PORT"))
+ if port == "" {
+ port = "8080"
+ }
+ host := strings.TrimSpace(os.Getenv("HOST"))
+ if host == "" {
+ host = strings.TrimSpace(os.Getenv("WAILS_SERVER_HOST"))
+ }
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ addr := host + ":" + port
+
+ server := &http.Server{
+ Addr: addr,
+ Handler: logRequests(mux),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ log.Printf("OPIc Flow server listening on http://%s", addr)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatal(err)
+ }
+}
+
+func registerAPI(mux *http.ServeMux, service *AppService) {
+ mux.HandleFunc("GET /api/settings", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, service.GetSettings())
+ })
+ mux.HandleFunc("POST /api/configure", func(w http.ResponseWriter, r *http.Request) {
+ var request ConfigureRequest
+ if !readJSON(w, r, &request) {
+ return
+ }
+ result, err := service.Configure(request)
+ writeResult(w, result, err)
+ })
+ mux.HandleFunc("POST /api/test-connection", func(w http.ResponseWriter, r *http.Request) {
+ writeResult(w, map[string]bool{"ok": true}, service.TestConnection())
+ })
+ mux.HandleFunc("POST /api/speech", func(w http.ResponseWriter, r *http.Request) {
+ var request struct {
+ Text string `json:"text"`
+ }
+ if !readJSON(w, r, &request) {
+ return
+ }
+ result, err := service.GenerateSpeech(request.Text)
+ writeResult(w, result, err)
+ })
+ mux.HandleFunc("POST /api/sessions", func(w http.ResponseWriter, r *http.Request) {
+ var request ExamConfig
+ if !readJSON(w, r, &request) {
+ return
+ }
+ result, err := service.StartSession(request)
+ writeResult(w, result, err)
+ })
+ mux.HandleFunc("POST /api/answers", func(w http.ResponseWriter, r *http.Request) {
+ var request SubmitAnswerRequest
+ if !readJSON(w, r, &request) {
+ return
+ }
+ result, err := service.SubmitAnswer(request)
+ writeResult(w, result, err)
+ })
+ mux.HandleFunc("POST /api/sessions/{sessionID}/finalize", func(w http.ResponseWriter, r *http.Request) {
+ result, err := service.FinalizeSession(r.PathValue("sessionID"))
+ writeResult(w, result, err)
+ })
+ mux.HandleFunc("GET /api/sessions/{sessionID}/report", func(w http.ResponseWriter, r *http.Request) {
+ result, err := service.GetReport(r.PathValue("sessionID"))
+ writeResult(w, result, err)
+ })
+}
+
+func registerStatic(mux *http.ServeMux) {
+ dist, err := fs.Sub(serverAssets, "frontend/dist")
+ if err != nil {
+ log.Fatalf("frontend assets are missing: %v", err)
+ }
+ fileServer := http.FileServer(http.FS(dist))
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ if _, err := fs.Stat(dist, strings.TrimPrefix(r.URL.Path, "/")); err != nil {
+ r.URL.Path = "/"
+ }
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+}
+
+func readJSON(w http.ResponseWriter, r *http.Request, target any) bool {
+ defer r.Body.Close()
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return false
+ }
+ return true
+}
+
+func writeResult(w http.ResponseWriter, value any, err error) {
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeJSON(w, value)
+}
+
+func writeJSON(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(value); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func logRequests(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ log.Printf("%s %s %s", r.Method, r.URL.Path, strconv.FormatInt(time.Since(start).Milliseconds(), 10)+"ms")
+ })
+}